chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:45 +08:00
commit 4b6817381b
3933 changed files with 525247 additions and 0 deletions
@@ -0,0 +1,292 @@
"""Composable slash-command registry for the interactive REPL."""
from __future__ import annotations
import os
import shlex
from collections.abc import Callable
from itertools import chain
from typing import Any
from rich.console import Console
from core.agent_harness.session.terminal_access import pop_turn_outcome_hint, session_terminal
from surfaces.interactive_shell.command_registry.agents import COMMANDS as AGENTS_COMMANDS
from surfaces.interactive_shell.command_registry.alerts import COMMANDS as ALERTS_COMMANDS
from surfaces.interactive_shell.command_registry.background_cmds import (
COMMANDS as BACKGROUND_COMMANDS,
)
from surfaces.interactive_shell.command_registry.cli_parity import (
COMMANDS as PARITY_COMMANDS,
)
from surfaces.interactive_shell.command_registry.diagnostics_cmds import (
COMMANDS as DIAGNOSTICS_COMMANDS,
)
from surfaces.interactive_shell.command_registry.gateway_cmds import (
COMMANDS as GATEWAY_COMMANDS,
)
from surfaces.interactive_shell.command_registry.help import COMMANDS as HELP_COMMANDS
from surfaces.interactive_shell.command_registry.integrations import (
COMMANDS as INTEGRATIONS_COMMANDS,
)
from surfaces.interactive_shell.command_registry.investigation import (
COMMANDS as INVESTIGATION_COMMANDS,
)
from surfaces.interactive_shell.command_registry.model import COMMANDS as MODEL_COMMANDS
from surfaces.interactive_shell.command_registry.model import (
switch_llm_provider,
switch_reasoning_model,
switch_toolcall_model,
)
from surfaces.interactive_shell.command_registry.privacy_cmds import (
COMMANDS as PRIVACY_COMMANDS,
)
from surfaces.interactive_shell.command_registry.rca_cmds import COMMANDS as RCA_COMMANDS
from surfaces.interactive_shell.command_registry.repl_data import (
load_llm_settings,
load_verified_integrations,
)
from surfaces.interactive_shell.command_registry.session_cmds import (
COMMANDS as SESSION_COMMANDS,
)
from surfaces.interactive_shell.command_registry.settings_cmds import (
COMMANDS as SETTINGS_COMMANDS,
)
from surfaces.interactive_shell.command_registry.suggestions import (
format_unknown_slash_message,
resolve_literal_slash_typo,
)
from surfaces.interactive_shell.command_registry.system import COMMANDS as SYSTEM_COMMANDS
from surfaces.interactive_shell.command_registry.tasks_cmds import COMMANDS as TASK_COMMANDS
from surfaces.interactive_shell.command_registry.theme import COMMANDS as THEME_COMMANDS
from surfaces.interactive_shell.command_registry.tools_cmds import COMMANDS as TOOLS_COMMANDS
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.command_registry.watch_cmds import COMMANDS as WATCH_COMMANDS
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
from surfaces.interactive_shell.utils.telemetry.console_capture import capture_console_segment
from surfaces.interactive_shell.utils.telemetry.turn_outcome import format_terminal_turn_outcome
from tools.interactive_shell.shared import allow_tool
_MERGED_SEQUENCE = tuple(
chain(
HELP_COMMANDS,
SESSION_COMMANDS,
THEME_COMMANDS,
BACKGROUND_COMMANDS,
SETTINGS_COMMANDS,
DIAGNOSTICS_COMMANDS,
INTEGRATIONS_COMMANDS,
MODEL_COMMANDS,
TOOLS_COMMANDS,
INVESTIGATION_COMMANDS,
RCA_COMMANDS,
TASK_COMMANDS,
WATCH_COMMANDS,
GATEWAY_COMMANDS,
PRIVACY_COMMANDS,
AGENTS_COMMANDS,
ALERTS_COMMANDS,
PARITY_COMMANDS,
SYSTEM_COMMANDS,
)
)
SLASH_COMMANDS: dict[str, SlashCommand] = {cmd.name: cmd for cmd in _MERGED_SEQUENCE}
# Slash commands that adopt a different session file must record the turn after
# the handler settles session identity (see /resume).
_DEFER_SLASH_RECORDING: frozenset[str] = frozenset({"/resume"})
def _latest_record_ok(session: Session, kind: str, *, default: bool = True) -> bool:
"""Return ``ok`` from the newest history row of ``kind`` after the handler runs."""
for entry in reversed(session.history):
if entry.get("type") == kind:
return bool(entry.get("ok", default))
return default
def _latest_slash_record(session: Session) -> dict[str, Any] | None:
for entry in reversed(session.history):
if entry.get("type") == "slash":
return entry
return None
def _attach_slash_analytics(
session: Session,
command_line: str,
*,
captured_output: str,
) -> None:
latest = _latest_slash_record(session)
ok = _latest_record_ok(session, "slash")
if latest is not None and latest.get("slash_outcome"):
response_text = str(latest.get("response_text") or "").strip()
else:
response_text = format_terminal_turn_outcome(
command_line,
kind="slash",
ok=ok,
captured_output=captured_output,
outcome_hint=pop_turn_outcome_hint(session),
include_captured_on_summary_only=session_terminal(session) is None,
)
session.complete_latest_record(
"slash",
response_text=response_text,
)
def dispatch_slash(
command_line: str,
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
policy_precleared: bool = False,
) -> bool:
"""Dispatch a slash command line. Returns False iff the REPL should exit.
When ``policy_precleared`` is True, skip the execution gate (caller already ran
:func:`execution_allowed`) and run the handler directly. Only valid for lines
the registry resolves to a known command, or bare ``/`` after an equivalent
gate for help.
"""
env_backup = os.environ.get("OPENSRE_INTERACTIVE")
if is_tty is False:
os.environ["OPENSRE_INTERACTIVE"] = "0"
stripped = command_line.strip()
slash_recorded = False
def record_slash(
*,
ok: bool = True,
response_text: str | None = None,
slash_outcome: str | None = None,
) -> None:
nonlocal slash_recorded
session.record(
"slash",
stripped,
ok=ok,
response_text=response_text,
slash_outcome=slash_outcome,
)
slash_recorded = True
try:
with capture_console_segment(console) as get_captured:
try:
if stripped == "/":
from surfaces.interactive_shell.command_registry.help import _cmd_help
if policy_precleared:
record_slash(ok=True)
return _cmd_help(session, console, [])
gate = allow_tool("slash")
if not execution_allowed(
gate,
session=session,
console=console,
action_summary=stripped,
confirm_fn=confirm_fn,
is_tty=is_tty,
):
record_slash(ok=False)
return True
record_slash(ok=True)
return _cmd_help(session, console, [])
parts = stripped.split()
if not parts:
return True
name = parts[0].lower()
if name in ("/watch", "/unwatch"):
head = parts[0]
body = stripped[len(head) :].strip()
try:
# Use POSIX mode on all platforms so quoted values are unwrapped
# consistently (e.g., --max-cpu "80" -> 80).
args = shlex.split(body, posix=True)
except ValueError:
args = body.split()
else:
args = parts[1:]
cmd = SLASH_COMMANDS.get(name)
if cmd is None:
typo_message = format_unknown_slash_message(
stripped,
command_names=tuple(SLASH_COMMANDS),
)
record_slash(
ok=False,
response_text=typo_message,
slash_outcome="unknown_command",
)
console.print()
console.print(typo_message)
return True
typo = resolve_literal_slash_typo(stripped, SLASH_COMMANDS)
if typo is not None:
record_slash(
ok=False,
response_text=typo.message,
slash_outcome=typo.outcome,
)
console.print()
console.print(typo.message)
return True
if cmd.validate_args is not None:
validation_error = cmd.validate_args(args)
if validation_error is not None:
record_slash(ok=False)
console.print(validation_error)
return True
if policy_precleared:
if name not in _DEFER_SLASH_RECORDING:
record_slash(ok=True)
return cmd.handler(session, console, args)
policy = allow_tool("slash")
if not execution_allowed(
policy,
session=session,
console=console,
action_summary=stripped,
confirm_fn=confirm_fn,
is_tty=is_tty,
):
record_slash(ok=False)
return True
if name not in _DEFER_SLASH_RECORDING:
record_slash(ok=True)
return cmd.handler(session, console, args)
finally:
if slash_recorded:
_attach_slash_analytics(
session,
stripped,
captured_output=get_captured(),
)
finally:
if is_tty is False:
if env_backup is None:
del os.environ["OPENSRE_INTERACTIVE"]
else:
os.environ["OPENSRE_INTERACTIVE"] = env_backup
__all__ = [
"SLASH_COMMANDS",
"SlashCommand",
"dispatch_slash",
"load_llm_settings",
"load_verified_integrations",
"switch_llm_provider",
"switch_reasoning_model",
"switch_toolcall_model",
]
@@ -0,0 +1,7 @@
"""Slash command: ``/fleet`` (registered local AI agent fleet view)."""
from __future__ import annotations
from surfaces.interactive_shell.command_registry.agents.core import COMMANDS
__all__ = ["COMMANDS"]
@@ -0,0 +1,45 @@
"""Rich presentation for fleet file-write conflict detection results."""
from __future__ import annotations
from datetime import UTC, datetime
from rich.markup import escape
from rich.table import Table
import platform.terminal.theme as ui_theme
from surfaces.interactive_shell.ui.components.rendering import repl_table
from tools.system.fleet_monitoring.conflicts import FileWriteConflict
_EMPTY_STATE = "no conflicts detected"
def _format_timestamp(timestamp: float) -> str:
return datetime.fromtimestamp(timestamp, tz=UTC).strftime("%H:%M:%S UTC")
def render_conflicts(conflicts: list[FileWriteConflict]) -> Table | str:
"""Render conflicts as a Rich table, or the empty-state string."""
if not conflicts:
return _EMPTY_STATE
table = repl_table(
title="Agent file-write conflicts",
title_style=ui_theme.BOLD_BRAND,
)
table.add_column("path", style="bold", overflow="fold")
table.add_column("agents", overflow="fold")
table.add_column("first seen", style=ui_theme.DIM)
table.add_column("last seen", style=ui_theme.DIM)
for conflict in conflicts:
table.add_row(
escape(conflict.path),
escape(", ".join(conflict.agents)),
_format_timestamp(conflict.first_seen),
_format_timestamp(conflict.last_seen),
)
return table
__all__ = ["render_conflicts"]
@@ -0,0 +1,471 @@
"""Slash command: ``/fleet`` (registered local AI agent fleet view).
Bare ``/fleet`` renders the registered-agents dashboard; subcommands
cover ``budget``, ``bus``, ``claim``, ``conflicts``, ``kill``, ``release``,
and ``trace`` (with more surfaces planned for monitor-local-agents).
"""
from __future__ import annotations
import math
import os
from collections import defaultdict
from pathlib import Path
from pydantic import ValidationError
from rich.console import Console
from rich.markup import escape
from rich.tree import Tree
from surfaces.interactive_shell.command_registry.agents.conflicts_view import render_conflicts
from surfaces.interactive_shell.command_registry.agents.kill import _cmd_agents_kill
from surfaces.interactive_shell.command_registry.agents.trace import _cmd_agents_trace
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import (
BOLD_BRAND,
DIM,
ERROR,
HIGHLIGHT,
WARNING,
print_repl_table,
render_agents_table,
repl_table,
)
from tools.system.fleet_monitoring.bus import BusMessage, subscribe
from tools.system.fleet_monitoring.config import (
agents_config_path,
load_agents_config,
set_agent_budget,
)
from tools.system.fleet_monitoring.conflicts import (
DEFAULT_WINDOW_SECONDS,
WriteEvent,
detect_conflicts,
)
from tools.system.fleet_monitoring.coordination import BranchClaims
from tools.system.fleet_monitoring.discovery import registered_and_discovered_agents
from tools.system.fleet_monitoring.registry import AgentRegistry
_AGENTS_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("budget", "view or edit per-agent hourly budgets"),
("bus", "live-tail the cross-agent context bus"),
("claim", "claim a branch for an agent"),
("conflicts", "show file-write conflicts between local AI agents"),
("kill", "SIGTERM → SIGKILL a local agent by PID"),
("release", "release a branch claim"),
("trace", "live tail of an agent's stdout by pid"),
("graph", "render the wait-on dependency graph as a tree"),
("wait", "mark <pid> as waiting on another pid: /fleet wait <pid> --on <other-pid>"),
)
def _opensre_agent_id() -> str:
return f"opensre:{os.getpid()}"
def _display_path(path: Path) -> str:
"""Replace the user's home prefix with ``~`` for cleaner CLI output."""
try:
return f"~/{path.relative_to(Path.home())}"
except ValueError:
return str(path)
def _print_config_error(console: Console, exc: ValidationError) -> None:
console.print(f"[{ERROR}]agents.yaml has invalid contents:[/] {escape(str(exc))}")
def _cmd_agents_list(console: Console) -> bool:
"""Render registered plus read-only discovered agents as a Rich table.
Bare ``/fleet`` resolves here. Explicit registry rows keep winning
on PID collisions; process discovery fills in Cursor, Claude Code,
Codex, Aider, and Gemini CLI sessions that the user never registered.
"""
registry = AgentRegistry()
render_agents_table(console, registered_and_discovered_agents(registry))
return True
def _format_bus_message(msg: BusMessage) -> str:
"""Render one ``BusMessage`` as ``[agent] path — summary`` (path optional)."""
parts = [f"[{HIGHLIGHT}]\\[{escape(msg.agent)}][/]"]
if msg.path:
parts.append(escape(msg.path))
parts.append("")
parts.append(escape(msg.summary))
return " ".join(parts)
def _cmd_agents_bus(console: Console) -> bool:
"""Live-tail the cross-agent context bus until ``Ctrl-C`` or broker exit.
Self-elects a broker if none is running, then streams each ``BusMessage``
as it arrives. The loop ends in three ways, each with explicit feedback:
``KeyboardInterrupt`` (user detached), broker disconnect (e.g. the
publishing process exited), or socket error.
"""
console.print(
f"[{DIM}]tailing /fleet bus — Ctrl-C to exit[/]",
)
try:
for msg in subscribe():
console.print(_format_bus_message(msg))
except KeyboardInterrupt:
console.print(f"[{DIM}](detached)[/]")
return True
except OSError as exc:
console.print(f"[{ERROR}]bus error:[/] {escape(str(exc))}")
return False
# ``subscribe()`` returned cleanly — the broker closed our connection
# (e.g. it stopped, or its host process exited). Surface that explicitly
# so the user isn't left wondering why the prompt came back.
console.print(f"[{DIM}]bus broker disconnected[/]")
return True
def _cmd_agents_conflicts(console: Console) -> bool:
# Real write-event collection comes from #1500 (filesystem blast-radius
# watcher), out of scope for this PR. Until that lands, the event source
# is empty and `/fleet conflicts` reports "no conflicts detected".
events: list[WriteEvent] = []
conflicts = detect_conflicts(
events,
window_seconds=DEFAULT_WINDOW_SECONDS,
opensre_agent_id=_opensre_agent_id(),
)
console.print(render_conflicts(conflicts))
return True
def _cmd_agents_claim(session: Session, console: Console, args: list[str]) -> bool:
"""Handle /fleet claim <branch> <agent-name>."""
if len(args) < 2:
console.print(f"[{ERROR}]Usage:[/] /fleet claim <branch> <agent-name>")
session.mark_latest(ok=False, kind="slash")
return False
branch = args[0].strip()
agent_name = args[1].strip()
# Look up the PID from the registry for the given agent name
registry = AgentRegistry()
pid = None
for record in registry.list():
if record.name == agent_name:
pid = record.pid
break
if pid is None:
console.print(
f"[{ERROR}]Agent '{escape(agent_name)}' not found in registry. "
"Use /fleet to see registered agents."
)
session.mark_latest(ok=False, kind="slash")
return False
claims = BranchClaims()
claim = claims.claim(branch, agent_name, pid)
if claim is None:
existing = claims.get(branch)
assert existing is not None # claim() only returns None when branch is held
console.print(
f"[{ERROR}]Cannot claim:[/] {escape(branch)} is already held by "
f"{escape(existing.agent_name)} (pid {existing.pid}). "
"Use /fleet release first."
)
session.mark_latest(ok=False, kind="slash")
return False
console.print(
f"[{HIGHLIGHT}]Branch {escape(branch)} now held by {escape(agent_name)} (pid {pid}).[/]"
)
return True
def _cmd_agents_release(session: Session, console: Console, args: list[str]) -> bool:
"""Handle /fleet release <branch>."""
if len(args) < 1:
console.print(f"[{ERROR}]Usage:[/] /fleet release <branch>")
session.mark_latest(ok=False, kind="slash")
return False
branch = args[0].strip()
claims = BranchClaims()
existing = claims.get(branch)
if existing is None:
console.print(f"[{ERROR}]{escape(branch)} is not currently held by any agent.")
session.mark_latest(ok=False, kind="slash")
return False
# release() cannot return None here because we confirmed existing is not None above
removed = claims.release(branch)
assert removed is not None
console.print(
f"[{HIGHLIGHT}]Released {escape(branch)} (was held by {escape(removed.agent_name)}).[/]"
)
return True
def _cmd_agents_budget(session: Session, console: Console, args: list[str]) -> bool:
"""View or edit per-agent budgets stored in ``~/.opensre/agents.yaml``.
No args -> render the current budgets as a table. Two args
(``<agent> <usd>``) -> set ``hourly_budget_usd`` for that agent and
persist. Anything else -> usage hint.
"""
if not args:
try:
config = load_agents_config()
except ValidationError as exc:
_print_config_error(console, exc)
session.mark_latest(ok=False, kind="slash")
return True
if not config.agents:
console.print(
f"[{DIM}]no per-agent budgets configured.[/] "
"use [bold]/fleet budget <agent> <usd>[/bold] to set one."
)
return True
table = repl_table(title="agent budgets", title_style=BOLD_BRAND)
table.add_column("agent", style="bold")
table.add_column("hourly $", justify="right")
table.add_column("progress min", justify="right")
table.add_column("error %", justify="right")
for name in sorted(config.agents):
budget = config.agents[name]
table.add_row(
escape(name),
f"${budget.hourly_budget_usd:.2f}" if budget.hourly_budget_usd is not None else "-",
str(budget.progress_minutes) if budget.progress_minutes is not None else "-",
f"{budget.error_rate_pct:.1f}" if budget.error_rate_pct is not None else "-",
)
print_repl_table(console, table)
return True
if len(args) != 2:
console.print(f"[{ERROR}]usage:[/] /fleet budget [<agent> <usd>]")
session.mark_latest(ok=False, kind="slash")
return True
name = args[0].strip()
raw_usd = args[1]
try:
usd = float(raw_usd)
except ValueError:
console.print(f"[{ERROR}]invalid budget:[/] {escape(raw_usd)} is not a number")
session.mark_latest(ok=False, kind="slash")
return True
# ``nan`` and ``inf`` slip past ``usd <= 0`` because both
# ``float("nan") <= 0`` and ``float("inf") <= 0`` are ``False``.
# Without this guard a stored ``nan`` would corrupt agents.yaml
# (next load fails Pydantic's ``gt=0`` since ``nan > 0`` is
# ``False``) and ``inf`` would render as ``$inf`` in the dashboard.
if not math.isfinite(usd) or usd <= 0:
console.print(f"[{ERROR}]invalid budget:[/] must be a positive finite number")
session.mark_latest(ok=False, kind="slash")
return True
try:
set_agent_budget(name, usd)
except ValidationError as exc:
_print_config_error(console, exc)
session.mark_latest(ok=False, kind="slash")
return True
console.print(
f"updated [bold]{escape(name)}[/]: ${usd:.2f}/hr -> {_display_path(agents_config_path())}"
)
return True
def _cmd_agents_wait(session: Session, console: Console, args: list[str]) -> bool:
"""Handle ``/fleet wait <pid> --on <other-pid>``.
Parse the two pids out of ``args``, registers the dependency in the agent registry.
"""
if len(args) != 3 or args[1] != "--on":
console.print(f"[{ERROR}]usage:[/] /fleet wait <pid> --on <other-pid>")
session.mark_latest(ok=False, kind="slash")
return True
try:
pid = int(args[0])
except ValueError:
console.print(f"[{ERROR}]invalid pid:[/] {escape(args[0])}")
session.mark_latest(ok=False, kind="slash")
return True
try:
on_pid = int(args[2])
except ValueError:
console.print(f"[{ERROR}]invalid other-pid:[/] {escape(args[2])}")
session.mark_latest(ok=False, kind="slash")
return True
if pid == on_pid:
console.print(f"[{ERROR}]invalid pid:[/] {pid} waiting for itself")
session.mark_latest(ok=False, kind="slash")
return True
registry = AgentRegistry()
waiter = registry.get(pid)
if waiter is None:
console.print(f"[{ERROR}]pid {pid} is not in the agent registry[/]")
session.mark_latest(ok=False, kind="slash")
return True
target = registry.get(on_pid)
if target is None:
console.print(f"[{ERROR}]pid {on_pid} is not in the agent registry[/]")
session.mark_latest(ok=False, kind="slash")
return True
waiter = waiter.add_waits_on(target)
registry.register(waiter)
console.print(
f"[{HIGHLIGHT}]{escape(waiter.name)} (pid {pid}) now waits on "
f"{escape(target.name)} (pid {on_pid}).[/]"
)
return True
def _cmd_agents_graph(console: Console) -> bool:
"""Render the ``waits_on`` dependency graph as a Rich tree.
Single-pass DFS over the inverse ``waits_on`` edges (depended-on
-> waiter), building the Rich tree as it descends. A back edge — a
pid re-encountered while still in the active path — is the
canonical cycle witness for a directed graph; a warning naming the agents
in the loop is emitted instead.
"""
def _label(pid: int, ppid: int | None = None) -> str:
r = records[pid]
if ppid is None:
return f"{escape(r.name)} ({pid}) \\[active]"
pr = records[ppid]
return f"{escape(r.name)} ({pid}) \\[waiting on {escape(pr.name)}]"
def _walk(pid: int, parent: Tree, path: list[int], visited: set[int]) -> list[int] | None:
for child in waiters_of.get(pid, []):
if child in visited:
return path[path.index(child) :] + [child]
path.append(child)
visited.add(child)
node = parent.add(_label(child, pid))
c = _walk(child, node, path, visited)
if c is not None:
return c
path.pop()
visited.remove(child)
return None
registry = AgentRegistry()
records = {r.pid: r for r in registry.list()}
if not records:
console.print(f"[{DIM}]no registered agents[/]")
return True
waiters_of: dict[int, list[int]] = defaultdict(list)
for record in records.values():
for on_pid in record.waits_on:
waiters_of[on_pid].append(record.pid)
# Roots are pids that wait on nothing. If every pid waits on
# something the graph is fully covered by a cycle — fall back to
# all pids so the walker enters somewhere and surfaces the back
# edge instead of silently exiting on an empty root list.
roots = [pid for pid, r in records.items() if not r.waits_on] or list(records)
trees: list[Tree] = []
chain: str | None = None
for root in roots:
tree = Tree(label=_label(root))
cycle = _walk(root, tree, [root], {root})
if cycle is not None:
chain = " -> ".join(f"{records[p].name} ({p})" for p in cycle)
break
trees.append(tree)
for i, tree in enumerate(trees):
console.print(tree)
if i != len(trees) - 1 and chain is None:
console.line()
if chain is not None:
console.print(f"[{WARNING}]: agent dependency cycle detected: {escape(chain)}.[/]")
return True
def _cmd_agents(session: Session, console: Console, args: list[str]) -> bool:
# The sampler is lazy: the first /fleet renders a cold snapshot and starts
# the background sampler so CPU/token columns warm up on subsequent views.
session.terminal.ensure_fleet_sampler_started()
if not args:
return _cmd_agents_list(console)
sub = args[0].lower().strip()
if sub == "budget":
return _cmd_agents_budget(session, console, args[1:])
if sub == "bus":
return _cmd_agents_bus(console)
if sub == "conflicts":
return _cmd_agents_conflicts(console)
if sub == "claim":
return _cmd_agents_claim(session, console, args[1:])
if sub == "kill":
return _cmd_agents_kill(session, console, args[1:])
if sub == "release":
return _cmd_agents_release(session, console, args[1:])
if sub == "trace":
return _cmd_agents_trace(session, console, args[1:])
if sub == "wait":
return _cmd_agents_wait(session, console, args[1:])
if sub == "graph":
return _cmd_agents_graph(console)
console.print(
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
"(try [bold]/fleet[/bold], [bold]/fleet budget[/bold], "
"[bold]/fleet bus[/bold], [bold]/fleet claim[/bold], "
"[bold]/fleet conflicts[/bold], [bold]/fleet kill[/bold], "
"[bold]/fleet release[/bold], [bold]/fleet trace[/bold], "
"[bold]/fleet wait[/bold] or [bold]/fleet graph[/bold])"
)
session.mark_latest(ok=False, kind="slash")
return True
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/fleet",
"Show and manage registered local AI agents.",
_cmd_agents,
usage=(
"/fleet",
"/fleet budget",
"/fleet bus",
"/fleet claim",
"/fleet conflicts",
"/fleet kill",
"/fleet release",
"/fleet trace",
"/fleet wait",
"/fleet graph",
),
first_arg_completions=_AGENTS_FIRST_ARGS,
),
]
@@ -0,0 +1,111 @@
"""/fleet kill subcommand."""
from __future__ import annotations
import os
from collections.abc import Callable
from rich.console import Console
from rich.markup import escape
from platform.analytics.events import Event
from platform.analytics.provider import get_analytics
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING
from tools.system.fleet_monitoring.lifecycle import TerminateResult, terminate
from tools.system.fleet_monitoring.registry import AgentRegistry
# Type alias for the optional confirmation callback (used for testing).
_ConfirmFn = Callable[[str], str]
def _cmd_agents_kill(
session: Session,
console: Console,
args: list[str],
*,
confirm_fn: _ConfirmFn | None = None,
) -> bool:
"""Handle ``/fleet kill <pid> [--force]``.
Sends SIGTERM, waits up to 5 s, then escalates to SIGKILL.
Asks for confirmation unless ``--force`` is present.
Emits an ``agent_killed`` analytics event on success.
"""
force = "--force" in args
positional = [a for a in args if a != "--force"]
if not positional:
console.print(f"[{ERROR}]usage:[/] /fleet kill <pid> [--force]")
session.mark_latest(ok=False, kind="slash")
return True
raw_pid = positional[0]
try:
pid = int(raw_pid)
except ValueError:
console.print(f"[{ERROR}]invalid pid:[/] {escape(raw_pid)} is not an integer")
session.mark_latest(ok=False, kind="slash")
return True
if pid == os.getpid():
console.print(f"[{ERROR}]refusing to kill the opensre process itself[/]")
session.mark_latest(ok=False, kind="slash")
return True
# Look up agent name from registry for friendlier output.
registry = AgentRegistry()
record = registry.get(pid)
label = f"{record.name} (pid {pid})" if record else f"pid {pid}"
if not force:
prompt_text = f"About to SIGTERM {label}. Confirm? [y/N] "
if confirm_fn is not None:
answer = confirm_fn(prompt_text)
else:
answer = console.input(prompt_text)
if answer.strip().lower() not in ("y", "yes"):
console.print(f"[{DIM}]aborted.[/]")
return True
try:
result: TerminateResult = terminate(pid)
except ProcessLookupError:
console.print(f"[{ERROR}]no such process:[/] pid {pid}")
session.mark_latest(ok=False, kind="slash")
return True
except PermissionError:
console.print(f"[{ERROR}]permission denied:[/] cannot signal pid {pid}")
session.mark_latest(ok=False, kind="slash")
return True
if result.exited:
console.print(
f"[{HIGHLIGHT}]Sent {result.signal_sent}. "
f"Process exited after {result.elapsed_seconds:.1f}s.[/]"
)
else:
console.print(
f"[{WARNING}]Sent {result.signal_sent} but process may still be running "
f"after {result.elapsed_seconds:.1f}s.[/]"
)
session.mark_latest(ok=False, kind="slash")
# Remove from the agent registry so `/fleet` no longer shows the dead PID.
# Only forget when the process actually exited — otherwise it stays visible
# for further monitoring or another kill attempt.
if record is not None and result.exited:
registry.forget(pid)
event = Event.AGENT_KILLED if result.exited else Event.AGENT_KILL_FAILED
get_analytics().capture(
event,
{
"pid": str(pid),
"agent_name": record.name if record else "unknown",
"signal": result.signal_sent,
"exited": result.exited,
"elapsed_seconds": str(round(result.elapsed_seconds, 2)),
},
)
return True
@@ -0,0 +1,157 @@
"""Live trace rendering and /fleet trace subcommand."""
from __future__ import annotations
import time
from contextlib import nullcontext
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console
from rich.live import Live
from rich.markup import escape
from rich.text import Text
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import BOLD_BRAND, DIM, ERROR
from tools.system.fleet_monitoring.registry import AgentRegistry
from tools.system.fleet_monitoring.tail import AttachSession, AttachUnsupported, attach
_TRACE_REFRESH_PER_SECOND = 10
# Match the throttle period to ``Live``'s refresh rate: under a 1k-line/sec
# agent the reader thread can publish chunks faster than Rich actually
# paints, and each ``live.update(Text.from_ansi(...))`` we make in
# excess just creates a Renderable Rich will discard at the next paint.
# Throttling the *call* to ``Live`` to one period bounds CPU under burst
# writers without affecting how fast the screen updates.
_TRACE_RENDER_PERIOD_S = 1.0 / _TRACE_REFRESH_PER_SECOND
# Cap the on-screen render to the most recent slice of the 4 MiB buffer
# so we don't reparse a 4 MiB string through Rich at 10 fps under burst
# writers. A few screens of context is plenty for "what is the agent
# doing right now"; the full tail is still in ``sess.buffer`` for any
# future drill-down view.
_TRACE_RENDER_TAIL_BYTES = 64 * 1024
def _render_trace_snapshot(live: Live, sess: AttachSession) -> None:
"""Decode the bounded snapshot with UTF-8 boundary safety for ``Live``.
ANSI sequences are interpreted (Rich); treat traced output like unfiltered ``kubectl logs``.
"""
snapshot = _slice_to_utf8_boundary(sess.buffer.snapshot(), _TRACE_RENDER_TAIL_BYTES)
live.update(Text.from_ansi(snapshot.decode("utf-8", errors="replace")))
def _slice_to_utf8_boundary(data: bytes, max_bytes: int) -> bytes:
"""Return the suffix of ``data`` that fits in ``max_bytes`` and starts
on a UTF-8 codepoint boundary.
A plain ``data[-max_bytes:]`` can land mid-codepoint, which decodes to
a leading U+FFFD under ``errors="replace"`` — visible as a stray
replacement character at the top of the live view. ``TailBuffer``
preserves boundaries by dropping whole chunks; we have to do the same
once we re-flatten and slice on the render side. UTF-8 continuation
bytes match ``10xxxxxx`` (``b & 0xC0 == 0x80``); a codepoint is at
most 4 bytes, so we walk forward at most 3 continuation bytes to
reach the next start byte.
"""
if len(data) <= max_bytes:
return data
sliced = data[-max_bytes:]
start = 0
while start < 4 and start < len(sliced) and (sliced[start] & 0xC0) == 0x80:
start += 1
return sliced[start:]
def _render_live_tail(console: Console, label: str, sess: AttachSession) -> None:
"""kubectl-logs-style render: a single Ctrl+C returns to the prompt.
Catches :class:`KeyboardInterrupt` inside the ``Live`` block and
swallows it so the REPL doesn't see a traceback. ``stream_to_console``
in ``streaming.py`` uses a double-press pattern because it's
rendering an LLM response that the user might *not* want to abort
on a stray keypress; a logs-style view is the inverse — one press
is the canonical "stop" signal.
"""
console.print(f"[{BOLD_BRAND}]trace {escape(label)}[/] [{DIM}]Ctrl+C to stop[/]")
isatty = getattr(console.file, "isatty", None)
stdout_context = patch_stdout(raw=True) if callable(isatty) and isatty() else nullcontext()
try:
with (
stdout_context,
Live(
Text(""),
console=console,
refresh_per_second=_TRACE_REFRESH_PER_SECOND,
transient=False,
vertical_overflow="visible",
) as live,
):
# Iterating ``sess`` is what drains the reader queue and
# appends to ``sess.buffer`` — the loop body only needs the
# *side effect* of advancing, not the chunk value, so the
# iteration variable is intentionally discarded.
# Seeded at 0.0 so the first iteration always renders (any
# ``time.monotonic() - 0.0`` clears the period); the throttle
# kicks in from the second iteration onward.
last_render = 0.0
pending = False
for _ in sess:
now = time.monotonic()
if now - last_render >= _TRACE_RENDER_PERIOD_S:
_render_trace_snapshot(live, sess)
last_render = now
pending = False
else:
pending = True
# Final flush: the last chunk(s) may have arrived inside a
# throttle window; render once after the loop so the user
# sees the very latest state instead of whatever was on
# screen at the last gated update.
if pending:
_render_trace_snapshot(live, sess)
except KeyboardInterrupt:
# kubectl-logs-style: a single Ctrl+C ends the trace and returns
# to the REPL prompt without propagating a traceback. The
# ``with sess:`` in the caller still runs and joins the reader
# thread, so this swallow is safe.
pass
if sess.producer_exited:
# Distinguish "the agent died and we noticed" from "the user
# asked us to stop" so a long unattended trace doesn't look the
# same as a Ctrl+C abort.
console.print(f"[{DIM}]· process exited[/]")
console.print(f"[{DIM}]· trace ended[/]")
def _cmd_agents_trace(session: Session, console: Console, args: list[str]) -> bool:
"""Live-tail an agent's stdout by pid; see :func:`_render_live_tail`.
Validates eagerly (``attach()`` raises :class:`AttachUnsupported`
synchronously on bad pid / unsupported fd type / missing file) so
we never enter the ``Live`` block on a target we cannot tail.
"""
if len(args) != 1:
console.print(f"[{ERROR}]usage:[/] /fleet trace <pid>")
session.mark_latest(ok=False, kind="slash")
return True
try:
pid = int(args[0])
except ValueError:
console.print(f"[{ERROR}]invalid pid:[/] {escape(args[0])}")
session.mark_latest(ok=False, kind="slash")
return True
record = AgentRegistry().get(pid)
label = f"{record.name} (pid {pid})" if record else f"pid {pid}"
try:
sess = attach(pid)
except AttachUnsupported as exc:
console.print(f"[{ERROR}]cannot trace {escape(label)}:[/] {escape(exc.reason)}")
session.mark_latest(ok=False, kind="slash")
return True
with sess:
_render_live_tail(console, label, sess)
return True
@@ -0,0 +1,38 @@
"""Slash command: /alerts — show alert listener status."""
from __future__ import annotations
from rich.console import Console
from core.domain.alerts.inbox import get_current_inbox
from platform.terminal.theme import BOLD_BRAND, DIM, HIGHLIGHT, WARNING
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import print_repl_table, repl_table
def _cmd_alerts(_session: Session, console: Console, _args: list[str]) -> bool:
inbox = get_current_inbox()
if inbox is None:
console.print(f"[{WARNING}]alert listener is not active.[/]")
return True
table = repl_table(title="Alert Inbox\n", title_style=BOLD_BRAND, show_header=False)
table.add_column("key", style="bold")
table.add_column("value")
table.add_row("status", f"[{HIGHLIGHT}]listening[/]")
table.add_row("queue depth", str(inbox.qsize))
table.add_row("dropped", str(inbox.dropped))
for alert in inbox.peek_last(5):
table.add_row(f"[{DIM}]recent[/]", f"{alert.alert_name or 'untitled'}{alert.text[:80]}")
print_repl_table(console, table)
return True
COMMANDS: list[SlashCommand] = [
SlashCommand("/alerts", "Show alert listener status.", _cmd_alerts),
]
__all__ = ["COMMANDS"]
@@ -0,0 +1,200 @@
"""Slash commands for session-local background investigation mode."""
from __future__ import annotations
from rich.console import Console
from rich.markup import escape
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import (
BOLD_BRAND,
DIM,
ERROR,
HIGHLIGHT,
print_repl_table,
repl_table,
)
_ALLOWED_NOTIFY_CHANNELS = ("email",)
_BACKGROUND_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("on", "enable background investigation mode"),
("off", "disable background investigation mode"),
("status", "show current background-mode state"),
("list", "list tracked background investigations"),
("show", "show one background investigation summary"),
("use", "promote a completed background RCA into active follow-up context"),
("notify", "show or update background notification channels"),
)
def _render_background_status(session: Session, console: Console) -> None:
table = repl_table(title="Background mode\n", title_style=BOLD_BRAND, show_header=False)
table.add_column("key", style="bold")
table.add_column("value")
table.add_row("enabled", "yes" if session.terminal.background_mode_enabled else "no")
table.add_row("tracked jobs", str(len(session.terminal.background_investigations)))
table.add_row(
"notify channels",
", ".join(session.terminal.background_notification_preferences.channels) or "none",
)
print_repl_table(console, table)
def _cmd_background(session: Session, console: Console, args: list[str]) -> bool:
sub = (args[0].lower() if args else "status").strip()
if sub == "on":
session.terminal.background_mode_enabled = True
console.print(f"[{HIGHLIGHT}]background mode enabled[/]")
return True
if sub == "off":
session.terminal.background_mode_enabled = False
console.print(f"[{DIM}]background mode disabled[/]")
return True
if sub == "status":
_render_background_status(session, console)
return True
if sub == "list":
if not session.terminal.background_investigations:
console.print(f"[{DIM}]no background investigations tracked in this session.[/]")
return True
table = repl_table(title="Background investigations\n", title_style=BOLD_BRAND)
table.add_column("id", style="bold")
table.add_column("status")
table.add_column("command")
table.add_column("root cause", overflow="fold")
for task_id, tracked_record in session.terminal.background_investigations.items():
table.add_row(
task_id,
tracked_record.status,
tracked_record.command,
escape(tracked_record.root_cause or ""),
)
print_repl_table(console, table)
return True
if sub == "show":
if len(args) < 2:
console.print(f"[{ERROR}]usage:[/] /background show <task_id>")
session.mark_latest(ok=False, kind="slash")
return True
task_id = args[1]
selected_record = session.terminal.background_investigations.get(task_id)
if selected_record is None:
console.print(f"[{ERROR}]unknown background task:[/] {escape(task_id)}")
session.mark_latest(ok=False, kind="slash")
return True
table = repl_table(
title=f"Background investigation: {task_id}\n",
title_style=BOLD_BRAND,
show_header=False,
)
table.add_column("key", style="bold")
table.add_column("value", overflow="fold")
table.add_row("status", selected_record.status)
table.add_row("command", selected_record.command)
table.add_row("root cause", escape(selected_record.root_cause or ""))
table.add_row(
"top analysis",
escape(
"; ".join(selected_record.top_analysis) if selected_record.top_analysis else ""
),
)
table.add_row(
"next steps",
escape("; ".join(selected_record.next_steps) if selected_record.next_steps else ""),
)
table.add_row(
"notify",
escape(
", ".join(f"{k}:{v}" for k, v in selected_record.notification_results.items())
or ""
),
)
print_repl_table(console, table)
return True
if sub == "use":
if len(args) < 2:
console.print(f"[{ERROR}]usage:[/] /background use <task_id>")
session.mark_latest(ok=False, kind="slash")
return True
task_id = args[1]
selected_record = session.terminal.background_investigations.get(task_id)
if selected_record is None:
console.print(f"[{ERROR}]unknown background task:[/] {escape(task_id)}")
session.mark_latest(ok=False, kind="slash")
return True
if not selected_record.final_state:
console.print(
f"[{ERROR}]background task has no completed RCA state yet:[/] {escape(task_id)}"
)
session.mark_latest(ok=False, kind="slash")
return True
session.last_state = dict(selected_record.final_state)
session.accumulate_from_state(selected_record.final_state)
console.print(
f"[{HIGHLIGHT}]background RCA active[/] "
f"[{DIM}]— follow-up context now points to {escape(task_id)}.[/]"
)
return True
if sub == "notify":
action = (args[1].lower() if len(args) > 1 else "list").strip()
if action == "list":
console.print(
f"[{DIM}]background notify channels:[/] "
f"{', '.join(session.terminal.background_notification_preferences.channels) or 'none'}"
)
return True
if action == "set":
if len(args) < 3:
console.print(f"[{ERROR}]usage:[/] /background notify set <channel[,channel...]>")
session.mark_latest(ok=False, kind="slash")
return True
requested = [part.strip().lower() for part in args[2].split(",") if part.strip()]
invalid = [part for part in requested if part not in _ALLOWED_NOTIFY_CHANNELS]
if invalid:
console.print(
f"[{ERROR}]invalid channel(s):[/] {escape(', '.join(invalid))} "
f"[{DIM}](allowed: {', '.join(_ALLOWED_NOTIFY_CHANNELS)})[/]"
)
session.mark_latest(ok=False, kind="slash")
return True
session.terminal.background_notification_preferences.set_channels(requested)
console.print(
f"[{HIGHLIGHT}]background notify channels set:[/] "
f"{', '.join(session.terminal.background_notification_preferences.channels)}"
)
return True
console.print(f"[{ERROR}]unknown notify subcommand:[/] {escape(action)}")
session.mark_latest(ok=False, kind="slash")
return True
console.print(
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
"(try [bold]/background status[/bold], [bold]/background list[/bold], "
"[bold]/background show <task_id>[/bold], or [bold]/background notify list[/bold])"
)
session.mark_latest(ok=False, kind="slash")
return True
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/background",
"Manage background investigation mode and completed RCA summaries.",
_cmd_background,
usage=(
"/background on",
"/background off",
"/background status",
"/background list",
"/background show <task_id>",
"/background use <task_id>",
"/background notify list",
"/background notify set <channel[,channel...]>",
),
first_arg_completions=_BACKGROUND_FIRST_ARGS,
)
]
__all__ = ["COMMANDS"]
@@ -0,0 +1,479 @@
"""Slash commands for CLI parity, delegating to the Click CLI via subprocess."""
from __future__ import annotations
import contextlib
import json
import os
import shlex
import subprocess
import tempfile
from pathlib import Path
from rich.console import Console
from rich.markup import escape
from core.agent_harness.session.terminal_access import (
session_terminal,
set_turn_outcome_hint,
)
from surfaces.interactive_shell.command_registry.suggestions import closest_choice
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session, TaskKind
from surfaces.interactive_shell.runtime.subprocess_runner import (
SYNTHETIC_TEST_TIMEOUT_SECONDS,
build_opensre_cli_argv,
start_background_cli_task,
)
from surfaces.interactive_shell.ui import DIM, ERROR, print_command_output
from surfaces.interactive_shell.utils.telemetry.turn_outcome import format_wizard_cli_outcome
_UPDATE_SUBPROCESS_TIMEOUT_SECONDS = 300
_BACKGROUND_TEST_SUBCOMMANDS = frozenset({"run", "synthetic", "cloudopsbench"})
_TEST_SUBCOMMANDS = ("list", "run", "synthetic", "cloudopsbench")
_TEST_PICKER_SELECTION_FILE_ENV = "OPENSRE_TEST_PICKER_SELECTION_FILE"
_PARENT_INTERACTIVE_SHELL_ENV = "OPENSRE_PARENT_INTERACTIVE_SHELL"
_HEADLESS_CLI_SUBPROCESS_TIMEOUT_SECONDS = 90.0
def publish_headless_slash_response(
session: Session,
*,
message: str,
ok: bool = True,
) -> None:
"""Pin an explicit slash reply for gateway/headless surfaces (wizards, setup)."""
session.complete_latest_record(
"slash",
response_text=message.strip(),
ok=ok,
slash_outcome="headless_guidance",
)
def _decode_subprocess_stream(value: str | bytes | None) -> str:
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return value
def _cli_command_succeeded(exit_code: int | None) -> bool:
return exit_code == 0
def run_cli_command(
console: Console,
args: list[str],
*,
session: Session | None = None,
subprocess_timeout: float | None = None,
capture_output: bool = False,
) -> bool:
"""Helper to delegate complex or interactive Click commands to a child process.
``subprocess_timeout`` caps how long ``subprocess.run`` waits before raising
:class:`~subprocess.TimeoutExpired`. Interactive flows use ``None`` so the
child can prompt as long as needed; callers that hit the network without a
TTY (like ``opensre update``) pass a bounded timeout.
``capture_output`` (default ``False``) makes the helper capture stdout/stderr
and replay them through ``console`` even without a timeout. Set this for
non-interactive delegated commands (e.g. ``opensre tests list``) so their
output appears inside the REPL buffer instead of bypassing ``console.print``
via the child's inherited stdout FD. Interactive commands like ``onboard``
must leave this ``False`` so the child's prompts stay attached to the real
TTY. Capture is also enabled automatically whenever a timeout is set.
**Return value:** Reports subprocess success for headless/gateway sessions
(``session`` with no terminal facet) so slash analytics can show failure.
On the interactive REPL, always returns ``True`` so delegated CLI failures
do not propagate to :func:`dispatch_slash` and exit the shell — failures
are still recorded via :meth:`Session.mark_latest`.
Ctrl+C sends :exc:`KeyboardInterrupt`, which subclasses :exc:`BaseException`
rather than :exc:`Exception`; it is handled here so the REPL survives and the
child process exits on SIGINT alongside the interrupted ``run`` call.
"""
console.print()
cmd = build_opensre_cli_argv(args)
headless = session is not None and session_terminal(session) is None
should_capture = capture_output or subprocess_timeout is not None or headless
if headless and subprocess_timeout is None:
subprocess_timeout = _HEADLESS_CLI_SUBPROCESS_TIMEOUT_SECONDS
child_env = os.environ.copy()
child_env[_PARENT_INTERACTIVE_SHELL_ENV] = "1"
if should_capture:
# Captured child stdout isn't a TTY, so force Rich colour there and parse
# it back in print_command_output — otherwise its styling would be lost.
child_env["FORCE_COLOR"] = "1"
exit_code: int | None = 0
try:
if should_capture:
captured_result = subprocess.run(
cmd,
check=False,
timeout=subprocess_timeout,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=child_env,
)
exit_code = captured_result.returncode
print_command_output(console, captured_result.stdout or "")
print_command_output(console, captured_result.stderr or "", style=ERROR)
if captured_result.returncode != 0:
console.print(
f"[{ERROR}]CLI command exited with non-zero code {captured_result.returncode}[/]"
)
else:
interactive_result = subprocess.run(cmd, check=False, env=child_env)
exit_code = interactive_result.returncode
if interactive_result.returncode != 0:
console.print(
f"[{ERROR}]CLI command exited with non-zero code {interactive_result.returncode}[/]"
)
except subprocess.TimeoutExpired as exc:
exit_code = None
print_command_output(console, _decode_subprocess_stream(exc.stdout))
print_command_output(console, _decode_subprocess_stream(exc.stderr), style=ERROR)
console.print(f"[{ERROR}]error:[/] CLI command timed out")
except KeyboardInterrupt:
exit_code = None
console.print(f"[{DIM}]CLI command cancelled (Ctrl+C).[/]")
except Exception as exc:
exit_code = None
console.print(f"[{ERROR}]error running CLI command:[/] {exc}")
console.print()
if session is not None and not should_capture:
set_turn_outcome_hint(session, format_wizard_cli_outcome(args, exit_code=exit_code))
ok = _cli_command_succeeded(exit_code)
if session is not None and not ok:
session.mark_latest(ok=False, kind="slash")
# Headless/gateway surfaces need the real exit status for slash analytics.
# Interactive REPL handlers must not return False to dispatch_slash on CLI
# failure — that would exit the shell (/exit is the only intentional False).
return ok if headless else True
def _cmd_onboard(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
if session_terminal(session) is None:
cli_cmd = " ".join(["uv run opensre onboard", *args]).strip()
message = (
"Onboarding is an interactive wizard (LLM provider, integrations, messaging). "
"It cannot run inside a Telegram chat.\n\n"
f"Run on the server:\n {cli_cmd}\n\n"
"Or configure individual services with "
"`/integrations setup <service>`."
)
console.print()
console.print(message)
publish_headless_slash_response(session, message=message)
return True
# The REPL loop treats ``/onboard`` as exclusive-stdin in
# ``runtime.utils.input_policy`` so the prompt_toolkit Application is torn down before
# this handler runs — the wizard subprocess therefore gets exclusive
# stdin and can drive its own interactive prompts without conflicting
# with the shell's UI.
return run_cli_command(console, ["onboard", *args], session=session)
def _cmd_auth(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
capture_output = not args or args[0].lower() in {"status", "logout"}
return run_cli_command(console, ["auth", *args], capture_output=capture_output, session=session)
def _cmd_login(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
return run_cli_command(console, ["auth", "login", *args], session=session)
def _cmd_remote(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
return run_cli_command(console, ["remote", *args], session=session)
def _catalog_task_kind(command: list[str]) -> TaskKind:
return TaskKind.SYNTHETIC_TEST if "synthetic" in command else TaskKind.CLI_COMMAND
def _argv_for_catalog_command(command: list[str]) -> list[str]:
if command[:1] == ["opensre"]:
return build_opensre_cli_argv(command[1:])
return command
def _start_test_command(
*,
session: Session,
console: Console,
command: list[str],
display_command: str | None = None,
) -> None:
shown = display_command or shlex.join(command)
session.record("cli_command", shown)
start_background_cli_task(
display_command=shown,
argv_list=_argv_for_catalog_command(command),
session=session,
console=console,
timeout_seconds=SYNTHETIC_TEST_TIMEOUT_SECONDS,
kind=_catalog_task_kind(command),
use_pty=True,
)
def _run_test_picker_for_background(session: Session, console: Console) -> bool:
console.print()
with contextlib.closing(
tempfile.NamedTemporaryFile(
prefix="opensre-test-selection-",
suffix=".json",
delete=False,
)
) as handle:
selection_path = Path(handle.name)
try:
env = dict(os.environ)
env[_TEST_PICKER_SELECTION_FILE_ENV] = str(selection_path)
result = subprocess.run(
build_opensre_cli_argv(["tests"]),
check=False,
env=env,
)
if result.returncode != 0:
console.print(f"[{ERROR}]CLI command exited with non-zero code {result.returncode}[/]")
console.print()
return True
if not selection_path.stat().st_size:
console.print()
return True
payload = json.loads(selection_path.read_text(encoding="utf-8"))
finally:
with contextlib.suppress(OSError):
selection_path.unlink()
if not isinstance(payload, list):
console.print(f"[{ERROR}]test picker returned an invalid selection[/]")
console.print()
return True
for item in payload:
if not isinstance(item, dict):
continue
command = item.get("command")
if not isinstance(command, list) or not all(isinstance(part, str) for part in command):
continue
display = item.get("command_display")
_start_test_command(
session=session,
console=console,
command=command,
display_command=display if isinstance(display, str) else None,
)
console.print()
return True
def _cmd_tests(session: Session, console: Console, args: list[str]) -> bool:
if not args:
return _run_test_picker_for_background(session, console)
subcommand = args[0].lower()
if subcommand in _BACKGROUND_TEST_SUBCOMMANDS:
_start_test_command(
session=session,
console=console,
command=["opensre", "tests", *args],
)
return True
if subcommand.startswith("-"):
return run_cli_command(console, ["tests", *args], capture_output=True)
if subcommand not in _TEST_SUBCOMMANDS:
suggestion = closest_choice(subcommand, _TEST_SUBCOMMANDS)
if suggestion is None:
console.print(
f"[{ERROR}]unknown tests subcommand:[/] {escape(args[0])} "
"(try [bold]/tests list[/bold], [bold]/tests run <test_id>[/bold], "
"[bold]/tests synthetic[/bold], or [bold]/tests cloudopsbench[/bold])"
)
else:
console.print(
f"[{ERROR}]unknown tests subcommand:[/] {escape(args[0])} "
f"Did you mean [bold]/tests {suggestion}[/bold]?"
)
session.mark_latest(ok=False, kind="slash")
return True
return run_cli_command(console, ["tests", *args], capture_output=True)
def _cmd_guardrails(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
# ``opensre guardrails`` and its subcommands are all non-interactive printers
# (init/test/audit/rules just ``click.echo``). Capture so the output — and
# Click's usage block when no subcommand is given — reaches the REPL buffer
# instead of bypassing ``console.print`` via the child's inherited stdout FD.
return run_cli_command(console, ["guardrails", *args], capture_output=True)
def _cmd_update(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
return run_cli_command(
console,
["update", *args],
subprocess_timeout=_UPDATE_SUBPROCESS_TIMEOUT_SECONDS,
session=session,
)
def _cmd_uninstall(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
return run_cli_command(console, ["uninstall", *args])
def _cmd_config(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
# Non-interactive click.echo only; capture so output reaches the REPL buffer
# instead of the child's inherited stdout while prompt_toolkit redraws.
return run_cli_command(console, ["config", *args], capture_output=True)
def _cmd_messaging(session: Session, console: Console, args: list[str]) -> bool:
# Non-interactive subcommands: capture so output renders through the REPL
# (inherited stdout gets clipped by prompt_toolkit's screen management).
return run_cli_command(console, ["messaging", *args], capture_output=True, session=session)
def _cmd_hermes(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
return run_cli_command(console, ["hermes", *args])
def _cmd_cron(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
return run_cli_command(console, ["cron", *args])
def _cmd_watchdog(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
return run_cli_command(console, ["watchdog", *args])
def _cmd_debug(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
return run_cli_command(console, ["debug", *args])
def _cmd_misses(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
# Non-interactive printers only (list/stats/export/convert) — capture so the
# output reaches the REPL buffer instead of the child's inherited stdout.
return run_cli_command(console, ["misses", *args], capture_output=True)
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/auth",
"Log in to LLM providers and inspect local auth state.",
_cmd_auth,
usage=("/auth", "/auth status", "/auth login deepseek", "/auth logout deepseek"),
),
SlashCommand(
"/login",
"Shortcut for LLM provider login.",
_cmd_login,
usage=("/login", "/login chatgpt", "/login claude", "/login deepseek"),
),
SlashCommand(
"/onboard",
"Run the interactive onboarding wizard.",
_cmd_onboard,
usage=("/onboard", "/onboard local_llm"),
),
SlashCommand(
"/remote",
"Connect to and trigger a remote deployed agent.",
_cmd_remote,
usage=(
"/remote health",
"/remote investigate",
"/remote ops",
"/remote pull",
"/remote trigger",
),
),
SlashCommand(
"/tests",
"Browse and run inventoried tests.",
_cmd_tests,
usage=("/tests", "/tests list", "/tests run", "/tests synthetic"),
first_arg_completions=tuple((name, f"/tests {name}") for name in _TEST_SUBCOMMANDS),
),
SlashCommand(
"/guardrails",
"Manage sensitive information guardrail rules.",
_cmd_guardrails,
usage=(
"/guardrails audit",
"/guardrails init",
"/guardrails rules",
"/guardrails test",
),
),
SlashCommand(
"/update",
"Check for a newer version and update if available.",
_cmd_update,
),
SlashCommand(
"/uninstall",
"Remove OpenSRE and all local data from this machine.",
_cmd_uninstall,
),
SlashCommand(
"/config",
"Show or edit local OpenSRE config.",
_cmd_config,
usage=("/config show", "/config set <key> <value>"),
),
SlashCommand(
"/messaging",
"Manage messaging security and identities.",
_cmd_messaging,
usage=(
"/messaging pair",
"/messaging allow",
"/messaging revoke",
"/messaging status",
),
),
SlashCommand(
"/hermes",
"Live-tail Hermes logs and send incidents to Telegram.",
_cmd_hermes,
usage=("/hermes watch",),
),
SlashCommand(
"/cron",
"Manage cron-driven scheduled deliveries.",
_cmd_cron,
usage=("/cron list", "/cron add", "/cron remove <id>", "/cron run <id>", "/cron logs <id>"),
),
SlashCommand(
"/watchdog",
"Monitor one process and send threshold alarms.",
_cmd_watchdog,
usage=("/watchdog --pid <pid> [--max-rss <size>] [--max-cpu <percent>]",),
examples=("/watchdog --pid 123 --max-rss 1G",),
),
SlashCommand(
"/debug",
"run targeted runtime diagnostics",
_cmd_debug,
),
SlashCommand(
"/misses",
"Triage investigation misses and export them as benchmark scenarios.",
_cmd_misses,
usage=(
"/misses list",
"/misses stats",
"/misses export --out <dir>",
"/misses convert <miss_id>",
),
),
]
@@ -0,0 +1,124 @@
"""Slash commands: session diagnostics (/status, /cost, /context)."""
from __future__ import annotations
from rich.console import Console
from rich.markup import escape
from config.llm_reasoning_effort import display_reasoning_effort
from core.agent_harness.accounting.token_accounting import format_token_total
from core.agent_harness.session.terminal_access import trust_mode_enabled
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.grounding.cli_reference import session_cli_reference
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import (
BOLD_BRAND,
DIM,
print_repl_table,
repl_table,
)
def _status_provider_display() -> str:
"""Render the active LLM provider, flagging a fallback away from configured."""
from config.config import get_configured_llm_provider, resolve_llm_settings_verbose
try:
resolution = resolve_llm_settings_verbose()
except Exception:
return get_configured_llm_provider()
if not resolution.fell_back:
return resolution.resolved_provider
from surfaces.interactive_shell.ui import WARNING
note = f"fallback from '{resolution.configured_provider}'"
if resolution.missing_key_env:
note += f": {resolution.missing_key_env} not set"
return f"{resolution.resolved_provider} [{WARNING}]({note})[/]"
def _incoming_alerts_status(session: Session) -> tuple[str, str]:
"""Return (label, value) for the incoming-alerts row; headless sessions have no inbox."""
alerts = getattr(session, "alerts", None)
if alerts is None:
return "incoming alerts", "0"
most_recent = alerts.most_recent
if most_recent is not None:
from surfaces.interactive_shell.ui.alerts import time_ago
age_str = time_ago(most_recent.received_at)
return "incoming alerts", f"{len(alerts.entries)} (last {age_str})"
return "incoming alerts", "0"
def _cmd_status(session: Session, console: Console, _args: list[str]) -> bool:
table = repl_table(title="Session status\n", title_style=BOLD_BRAND, show_header=False)
table.add_column("key", style="bold")
table.add_column("value")
table.add_row("interactions", str(len(session.history)))
alert_key, alert_value = _incoming_alerts_status(session)
table.add_row(alert_key, alert_value)
table.add_row("last investigation", "yes" if session.last_state else "none")
table.add_row("trust mode", "on" if trust_mode_enabled(session) else "off")
table.add_row("reasoning effort", display_reasoning_effort(session.reasoning_effort))
table.add_row("provider", _status_provider_display())
table.add_row(
"grounding cli cache",
session_cli_reference(session).stats().render(),
)
for source in session.grounding.iter_sources():
table.add_row(f"grounding {source.name} cache", source.stats_fn().render())
acc = session.accumulated_context
if acc:
table.add_row("accumulated context", ", ".join(sorted(acc.keys())))
print_repl_table(console, table)
return True
def _cmd_cost(session: Session, console: Console, _args: list[str]) -> bool:
title = "Session cost"
if session.tokens.has_estimates:
title = "Session cost (includes estimates)"
table = repl_table(title=f"{title}\n", title_style=BOLD_BRAND, show_header=False)
table.add_column("key", style="bold")
table.add_column("value")
table.add_row("history entries", str(len(session.history)))
if session.tokens.call_count:
table.add_row("llm calls", str(session.tokens.call_count))
if session.tokens.totals:
for direction in ("input", "output"):
label, value = format_token_total(session, direction=direction)
table.add_row(label, value)
else:
table.add_row("token usage", f"[{DIM}]no LLM usage recorded yet[/]")
print_repl_table(console, table)
return True
def _cmd_context(session: Session, console: Console, _args: list[str]) -> bool:
if not session.accumulated_context:
console.print(f"[{DIM}]no infra context accumulated yet.[/]")
return True
table = repl_table(title="Accumulated context\n", title_style=BOLD_BRAND, show_header=False)
table.add_column("key", style="bold")
table.add_column("value")
for k, v in sorted(session.accumulated_context.items()):
table.add_row(k, escape(str(v)))
print_repl_table(console, table)
return True
COMMANDS: list[SlashCommand] = [
SlashCommand("/status", "Show session status.", _cmd_status),
SlashCommand("/cost", "Show token usage and session cost.", _cmd_cost),
SlashCommand("/context", "Show accumulated infra context.", _cmd_context),
]
__all__ = ["COMMANDS"]
@@ -0,0 +1,72 @@
"""Slash command: /gateway — control the OpenSRE gateway daemon."""
from __future__ import annotations
from rich.console import Console
from rich.markup import escape
from gateway.daemon import (
GATEWAY_LOG_FILE,
gateway_daemon_pid,
read_component_status,
read_gateway_log_tail,
start_gateway_daemon,
stop_gateway_daemon,
)
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT
_USAGE = "/gateway [start|stop|status|logs [lines]]"
def _print_outcome(console: Console, ok: bool, message: str) -> None:
console.print(f"[{HIGHLIGHT if ok else ERROR}]{escape(message)}[/]")
def _cmd_gateway(_session: Session, console: Console, args: list[str]) -> bool:
sub = args[0].lower() if args else "status"
if sub == "start":
_print_outcome(console, *start_gateway_daemon())
console.print(f"[{DIM}]logs: {GATEWAY_LOG_FILE} — /gateway logs to tail[/]")
elif sub == "stop":
_print_outcome(console, *stop_gateway_daemon())
elif sub == "status":
pid = gateway_daemon_pid()
state = f"[{HIGHLIGHT}]running (pid {pid})[/]" if pid else f"[{DIM}]stopped[/]"
console.print(f"OpenSRE gateway: {state}")
for name, detail in read_component_status().items():
console.print(f" {escape(name)}: {escape(detail)}")
console.print(f"[{DIM}]logs: {GATEWAY_LOG_FILE}[/]")
elif sub == "logs":
lines = int(args[1]) if len(args) > 1 and args[1].isdigit() else 30
if tail := read_gateway_log_tail(lines):
console.print(escape(tail))
else:
console.print(f"[{DIM}]no gateway logs yet at {GATEWAY_LOG_FILE}[/]")
else:
console.print(f"[{ERROR}]usage:[/] {_USAGE}")
return True
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/gateway",
"Control the background OpenSRE gateway daemon: start, stop, status, logs.",
_cmd_gateway,
usage=(_USAGE,),
notes=(
"The gateway daemon runs the web health app, Telegram chat, and the "
"task scheduler; logs are stored in ~/.opensre/gateway/gateway.log.",
),
first_arg_completions=(
("start", "start the gateway daemon (web, telegram, scheduler)"),
("stop", "stop the gateway daemon"),
("status", "show the daemon and its components"),
("logs", "print recent gateway log lines"),
),
),
]
__all__ = ["COMMANDS"]
@@ -0,0 +1,198 @@
"""Slash commands: /help and /?."""
from __future__ import annotations
from collections.abc import Sequence
from rich.console import Console
from rich.markup import escape
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import ERROR
from surfaces.interactive_shell.ui.components.choice_menu import repl_tty_interactive
from surfaces.interactive_shell.ui.help.help_menu import (
HelpSection,
choose_help_command,
render_command_detail,
render_help_index,
render_section_detail,
)
QUICK_ACCESS_COMMANDS: list[str] = [
"/investigate",
"/integrations",
"/model",
"/health",
"/watch",
"/status",
"/help",
]
def _quick_access_section() -> HelpSection:
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS
cmds = [SLASH_COMMANDS[n] for n in QUICK_ACCESS_COMMANDS if n in SLASH_COMMANDS]
return ("Quick Access", cmds)
def _raw_help_sections() -> list[HelpSection]:
from surfaces.interactive_shell.command_registry.agents import COMMANDS as AGENTS_CMDS
from surfaces.interactive_shell.command_registry.alerts import COMMANDS as ALERTS_CMDS
from surfaces.interactive_shell.command_registry.background_cmds import (
COMMANDS as BACKGROUND_CMDS,
)
from surfaces.interactive_shell.command_registry.cli_parity import (
COMMANDS as PARITY_COMMANDS,
)
from surfaces.interactive_shell.command_registry.diagnostics_cmds import (
COMMANDS as DIAGNOSTICS_CMDS,
)
from surfaces.interactive_shell.command_registry.gateway_cmds import (
COMMANDS as GATEWAY_CMDS,
)
from surfaces.interactive_shell.command_registry.integrations import COMMANDS as INT_CMDS
from surfaces.interactive_shell.command_registry.investigation import COMMANDS as INV_CMDS
from surfaces.interactive_shell.command_registry.model import COMMANDS as MODEL_CMDS
from surfaces.interactive_shell.command_registry.privacy_cmds import (
COMMANDS as PRIVACY_CMDS,
)
from surfaces.interactive_shell.command_registry.rca_cmds import COMMANDS as RCA_CMDS
from surfaces.interactive_shell.command_registry.session_cmds import (
COMMANDS as SESSION_CMDS,
)
from surfaces.interactive_shell.command_registry.settings_cmds import (
COMMANDS as SETTINGS_CMDS,
)
from surfaces.interactive_shell.command_registry.system import COMMANDS as SYS_CMDS
from surfaces.interactive_shell.command_registry.tasks_cmds import COMMANDS as TASK_CMDS
from surfaces.interactive_shell.command_registry.theme import COMMANDS as THEME_CMDS
from surfaces.interactive_shell.command_registry.tools_cmds import COMMANDS as TOOLS_CMDS
from surfaces.interactive_shell.command_registry.watch_cmds import COMMANDS as WATCH_CMDS
return [
_quick_access_section(),
("Help", list(COMMANDS)),
(
"Session",
list(SESSION_CMDS)
+ list(BACKGROUND_CMDS)
+ list(SETTINGS_CMDS)
+ list(DIAGNOSTICS_CMDS),
),
("Integrations, Models & Tools", list(INT_CMDS) + list(MODEL_CMDS) + list(TOOLS_CMDS)),
("Investigation", list(INV_CMDS) + list(RCA_CMDS)),
("Privacy", list(PRIVACY_CMDS)),
("Tasks", list(TASK_CMDS) + list(WATCH_CMDS) + list(GATEWAY_CMDS)),
("Theme", list(THEME_CMDS)),
("Agents", list(AGENTS_CMDS)),
("Alerts", list(ALERTS_CMDS)),
("CLI (parity)", list(PARITY_COMMANDS)),
("System", list(SYS_CMDS)),
]
_QUICK_ACCESS_SECTION_NAME = "Quick Access"
def _help_sections() -> list[HelpSection]:
"""Return user-visible help sections with duplicate command names hidden.
The "Quick Access" section is intentionally exempted from the dedup set so
its curated commands remain visible in their canonical sections too (e.g.
``/help investigation`` still contains ``/investigate``).
"""
seen: set[str] = set()
sections: list[HelpSection] = []
for section_name, commands in _raw_help_sections():
visible: list[SlashCommand] = []
is_quick_access = section_name == _QUICK_ACCESS_SECTION_NAME
for command in commands:
if command.name in seen:
continue
visible.append(command)
if not is_quick_access:
seen.add(command.name)
sections.append((section_name, visible))
return sections
def _find_command(sections: Sequence[HelpSection], target: str) -> SlashCommand | None:
normalized = target.strip().lower()
if not normalized:
return None
if not normalized.startswith("/"):
normalized = f"/{normalized}"
for _section_name, commands in sections:
for command in commands:
if command.name.lower() == normalized:
return command
return None
def _find_section(
sections: Sequence[HelpSection],
target: str,
) -> tuple[str, Sequence[SlashCommand]] | None:
normalized = target.strip().lower().replace("-", " ")
for section_name, commands in sections:
aliases = {
section_name.lower(),
section_name.lower().replace("&", "and"),
section_name.lower().replace(" & ", " "),
}
if normalized in aliases:
return section_name, commands
return None
def _cmd_help(_session: Session, console: Console, args: list[str]) -> bool:
sections = _help_sections()
if args:
target = " ".join(args).strip()
if target.lower() in {"all", "commands"}:
render_help_index(console, sections)
return True
if not target.startswith("/"):
section = _find_section(sections, target)
if section is not None:
section_name, commands = section
render_section_detail(console, section_name, commands)
return True
command = _find_command(sections, target)
if command is not None:
render_command_detail(console, command)
return True
console.print(f"[{ERROR}]unknown help topic:[/] {escape(target)}")
console.print(
"Try [bold]/help[/bold], [bold]/help /model[/bold], or [bold]/help tasks[/bold]."
)
return True
if repl_tty_interactive():
selected = choose_help_command(sections)
if selected:
# Re-dispatch the selected slash command so Enter in the help picker
# runs the command directly instead of only opening details.
from surfaces.interactive_shell.command_registry import dispatch_slash
return dispatch_slash(selected, _session, console)
return True
render_help_index(console, sections)
return True
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/help",
"Show available commands.",
_cmd_help,
usage=("/help", "/help <command>", "/help <category>"),
examples=("/help /model", "/help tasks"),
),
SlashCommand("/?", "Shortcut for /help.", _cmd_help),
]
__all__ = ["COMMANDS", "QUICK_ACCESS_COMMANDS"]
@@ -0,0 +1,508 @@
"""Slash commands for /integrations and /mcp."""
from __future__ import annotations
from rich.console import Console
from rich.markup import escape
import surfaces.interactive_shell.command_registry.repl_data as repl_data
from core.agent_harness.session.terminal_access import session_terminal
from surfaces.interactive_shell.command_registry.cli_parity import (
publish_headless_slash_response,
run_cli_command,
)
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import (
BOLD_BRAND,
DIM,
ERROR,
HIGHLIGHT,
MCP_INTEGRATION_SERVICES,
WARNING,
render_integrations_table,
render_mcp_table,
repl_table,
)
from surfaces.interactive_shell.ui.components.choice_menu import (
CRUMB_SEP,
prepare_repl_output_line,
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
from surfaces.interactive_shell.ui.components.rendering import (
_repl_table_width,
print_repl_table,
repl_print,
)
_ROOT_INTEGRATIONS = "/integrations"
_ROOT_MCP = "/mcp"
_MAX_OBSERVATION_DETAIL_CHARS = 160
def _record_integrations_observation(session: Session, results: list[dict[str, str]]) -> None:
"""Stash a compact text view of verification results for agent summarization.
Lets the agent answer questions like "is sentry installed?" by summarizing
what ``/integrations`` actually found, instead of leaving the user with only
a raw table. Kept plain-text and bounded so it is cheap to feed back to the
assistant.
"""
lines: list[str] = []
for record in results:
service = str(record.get("service", "")).strip()
if not service:
continue
status = str(record.get("status", "")).strip() or "unknown"
detail = str(record.get("detail", "")).strip()
if len(detail) > _MAX_OBSERVATION_DETAIL_CHARS:
detail = f"{detail[: _MAX_OBSERVATION_DETAIL_CHARS - 1]}"
line = f"- {service}: {status}"
if detail:
line += f" ({detail})"
lines.append(line)
if lines:
session.agent.last_observation = "Integration status from `/integrations`:\n" + "\n".join(
lines
)
def _record_integration_show_observation(session: Session, match: dict[str, str]) -> None:
"""Stash a compact text view of a single integration's verified details."""
lines: list[str] = []
for key, value in match.items():
text = str(value).strip()
if len(text) > _MAX_OBSERVATION_DETAIL_CHARS:
text = f"{text[: _MAX_OBSERVATION_DETAIL_CHARS - 1]}"
lines.append(f"- {key}: {text}")
if lines:
session.agent.last_observation = (
"Integration detail from `/integrations show`:\n" + "\n".join(lines)
)
def _configured_service_choices() -> list[tuple[str, str]]:
"""Build picker choices from configured integrations (no live verification)."""
return [(name, name) for name in repl_data.configured_integration_names()]
def _handle_remove(session: Session, console: Console, service: str | None) -> bool:
"""Remove an integration with a native inline-picker confirmation (no subprocess)."""
from integrations.registry import resolve_management_service
from integrations.store import remove_integration
from platform.analytics.cli import capture_integration_removed
svc = resolve_management_service(service) if service else service
if not svc:
if not repl_tty_interactive():
repl_print(console, f"[{DIM}]usage:[/] /integrations remove <service>")
session.mark_latest(ok=False, kind="slash")
return True
choices = _configured_service_choices()
if not choices:
repl_print(console, f"[{DIM}]no integrations in store to remove.[/]")
return True
svc = repl_choose_one(
title="select integration to remove",
breadcrumb=f"{_ROOT_INTEGRATIONS}{CRUMB_SEP}remove",
choices=choices,
)
if not svc:
return True
if repl_tty_interactive():
confirmed = repl_choose_one(
title=f"remove '{escape(svc)}'?",
breadcrumb=f"{_ROOT_INTEGRATIONS}{CRUMB_SEP}remove{CRUMB_SEP}{escape(svc)}",
choices=[
("no", "No, cancel"),
("yes", f"Yes, remove '{svc}'"),
],
)
prepare_repl_output_line()
if confirmed != "yes":
repl_print(console, f"[{DIM}]cancelled.[/]")
session.refresh_integration_state()
return True
else:
import sys
try:
import questionary
confirmed_bool = questionary.confirm(f" Remove '{svc}'?", default=False).ask()
except (EOFError, KeyboardInterrupt):
session.refresh_integration_state()
return True
if not confirmed_bool:
print(" Cancelled.", file=sys.stderr)
session.refresh_integration_state()
return True
if remove_integration(svc):
repl_print(console, f"[{HIGHLIGHT}]removed '{escape(svc)}'.[/]")
capture_integration_removed(svc)
if svc == "github":
from surfaces.interactive_shell.runtime.startup.first_launch_github import (
clear_github_login_deferral,
)
clear_github_login_deferral()
else:
repl_print(console, f"[{ERROR}]no integration found for:[/] {escape(svc)}")
session.mark_latest(ok=False, kind="slash")
session.refresh_integration_state()
return True
def _mcp_service_choices() -> list[tuple[str, str]]:
names = [
name
for name in repl_data.configured_integration_names()
if name in MCP_INTEGRATION_SERVICES
]
return [(name, name) for name in names]
def _print_verify_summary(
console: Console, results: list[dict[str, str]], *, single_service: bool
) -> None:
failed = [r for r in results if r.get("status") in ("failed", "missing")]
if single_service:
if not results:
return
service = escape(str(results[0].get("service", "?")))
style = WARNING if failed else HIGHLIGHT
detail = "needs attention" if failed else "ok"
repl_print(console, f"[{style}]{service} {detail}.[/]")
return
if failed:
repl_print(console, f"[{WARNING}]{len(failed)} integration(s) need attention.[/]")
else:
repl_print(console, f"[{HIGHLIGHT}]all integrations ok.[/]")
def _run_verify(session: Session, console: Console, service: str | None = None) -> bool:
normalized = ""
if service is not None:
from integrations.registry import SUPPORTED_VERIFY_SERVICES, resolve_management_service
normalized = resolve_management_service(service)
if normalized not in SUPPORTED_VERIFY_SERVICES:
repl_print(
console,
f"[{ERROR}]unsupported verify target:[/] {escape(normalized)} "
f"(try [bold]/verify[/bold] with no args to verify all)",
)
session.mark_latest(ok=False, kind="slash")
return True
prepare_repl_output_line()
label = escape(normalized) if service is not None else "integrations"
with console.status(f"[{DIM}]Verifying {label}…[/]", spinner="dots"):
if service is not None:
match = repl_data.verify_integration(normalized)
if match is None:
repl_print(console, f"[{ERROR}]service not found:[/] {escape(normalized)}")
session.mark_latest(ok=False, kind="slash")
return True
results = [match]
else:
results = repl_data.load_verified_integrations()
_record_integrations_observation(session, results)
render_integrations_table(console, results)
_print_verify_summary(console, results, single_service=service is not None)
return True
def _cmd_verify(session: Session, console: Console, args: list[str]) -> bool:
return _cmd_integrations(session, console, ["verify", *args])
def _render_integration_show(session: Session, console: Console, service: str) -> bool:
"""Verify and print one integration. Returns False when the service is unknown."""
from integrations.registry import resolve_management_service
normalized = resolve_management_service(service)
configured = set(repl_data.configured_integration_names())
if normalized not in configured:
repl_print(console, f"[{ERROR}]service not found:[/] {escape(normalized)}")
return False
prepare_repl_output_line()
with console.status(
f"[{DIM}]Verifying {escape(normalized)}…[/]",
spinner="dots",
):
match = repl_data.verify_integration(normalized)
if match is None:
repl_print(console, f"[{ERROR}]service not found:[/] {escape(normalized)}")
return False
_record_integration_show_observation(session, match)
width = _repl_table_width(console)
table = repl_table(
title=f"Integration: {normalized}",
title_style=BOLD_BRAND,
show_header=False,
width=width,
)
table.add_column("key", style="bold", no_wrap=True)
value_width = max(20, width - 20)
table.add_column("value", overflow="fold", max_width=value_width)
for key, value in match.items():
table.add_row(escape(key), escape(str(value)))
print_repl_table(console, table)
return True
def _run_integrations_setup(session: Session, console: Console, args: list[str]) -> bool:
headless = session_terminal(session) is None
if len(args) < 2:
# Bare setup delegates to the CLI service picker on the REPL; headless
# surfaces (Telegram) have no picker, so return usage guidance instead.
if not headless:
result = run_cli_command(console, ["integrations", "setup"])
session.refresh_integration_state()
return result
repl_print(console, f"[{DIM}]usage:[/] /integrations setup <service>")
publish_headless_slash_response(
session, message="Usage: /integrations setup <service>", ok=False
)
return True
service = args[1]
cli_cmd = " ".join(["uv run opensre integrations setup", service, *args[2:]]).strip()
if headless:
message = (
f"{escape(service)} setup needs interactive credentials (API keys, URLs, tokens) "
f"and cannot finish in Telegram.\n\n"
f"Run on the server:\n {cli_cmd}\n\n"
"Then check status with `/integrations list` or "
f"`/integrations verify {escape(service)}`."
)
repl_print(console, message)
publish_headless_slash_response(session, message=message, ok=True)
session.refresh_integration_state()
return True
result = run_cli_command(
console,
["integrations", "setup", service, *args[2:]],
session=session,
)
session.refresh_integration_state()
return result
def _cmd_integrations(session: Session, console: Console, args: list[str]) -> bool:
if not args and repl_tty_interactive():
return _interactive_integrations_menu(session, console)
sub = (args[0].lower() if args else "list").strip()
if sub in ("list", "ls"):
prepare_repl_output_line()
with console.status(f"[{DIM}]Verifying integrations…[/]", spinner="dots"):
results = repl_data.load_verified_integrations()
_record_integrations_observation(session, results)
render_integrations_table(console, results)
return True
if sub == "verify":
if len(args) > 2:
repl_print(
console,
f"[{DIM}]usage:[/] /integrations verify [service] "
f"(or [bold]/verify [service][/bold])",
)
session.mark_latest(ok=False, kind="slash")
return True
return _run_verify(session, console, args[1] if len(args) == 2 else None)
if sub == "setup":
return _run_integrations_setup(session, console, args)
if sub == "remove":
return _handle_remove(session, console, args[1] if len(args) > 1 else None)
if sub == "show":
if len(args) < 2:
repl_print(console, f"[{DIM}]usage:[/] /integrations show <service>")
session.mark_latest(ok=False, kind="slash")
return True
if not _render_integration_show(session, console, args[1]):
session.mark_latest(ok=False, kind="slash")
return True
repl_print(
console,
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
"(try [bold]/integrations list[/bold], [bold]/integrations verify[/bold], "
"or [bold]/integrations show <service>[/bold])",
)
session.mark_latest(ok=False, kind="slash")
return True
def _interactive_integrations_menu(session: Session, console: Console) -> bool:
root = _ROOT_INTEGRATIONS
while True:
sub = repl_choose_one(
title="integrations",
breadcrumb=root,
choices=[
("list", "/integrations list"),
("verify", "/integrations verify"),
("show", "/integrations show <service>"),
("setup", "/integrations setup <service>"),
("remove", "/integrations remove <service>"),
("done", "done"),
],
)
if sub is None or sub == "done":
return True
show_section_break = False
if sub == "list":
_cmd_integrations(session, console, ["list"])
show_section_break = True
elif sub == "verify":
_cmd_integrations(session, console, ["verify"])
show_section_break = True
elif sub == "setup":
_cmd_integrations(session, console, ["setup"])
show_section_break = True
elif sub == "show":
choices = _configured_service_choices()
if not choices:
repl_print(console, f"[{DIM}]no integrations in store to show.[/]")
show_section_break = True
else:
svc = repl_choose_one(
title="service",
breadcrumb=f"{root}{CRUMB_SEP}show",
choices=choices,
)
if svc and _render_integration_show(session, console, svc):
show_section_break = True
elif sub == "remove":
_handle_remove(session, console, None)
show_section_break = True
if show_section_break:
repl_section_break(console)
def _cmd_mcp(session: Session, console: Console, args: list[str]) -> bool:
if not args and repl_tty_interactive():
return _interactive_mcp_menu(session, console)
sub = (args[0].lower() if args else "list").strip()
if sub in ("list", "ls"):
render_mcp_table(console, repl_data.load_verified_integrations())
return True
if sub == "connect":
return _run_integrations_setup(session, console, ["setup", *args[1:]])
if sub == "disconnect":
return _handle_remove(session, console, args[1] if len(args) > 1 else None)
console.print(
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
"(try [bold]/mcp list[/bold], [bold]/mcp connect[/bold], or [bold]/mcp disconnect[/bold])"
)
return True
def _interactive_mcp_menu(session: Session, console: Console) -> bool:
root = _ROOT_MCP
while True:
sub = repl_choose_one(
title="mcp",
breadcrumb=root,
choices=[
("list", "/mcp list"),
("connect", "/mcp connect <server>"),
("disconnect", "/mcp disconnect <server>"),
("done", "done"),
],
)
if sub is None or sub == "done":
return True
show_section_break = False
if sub == "list":
_cmd_mcp(session, console, ["list"])
show_section_break = True
elif sub == "connect":
_cmd_mcp(session, console, ["connect"])
show_section_break = True
elif sub == "disconnect":
choices = _mcp_service_choices()
if not choices:
repl_print(console, f"[{DIM}]no MCP servers configured.[/]")
show_section_break = True
else:
svc = repl_choose_one(
title="server",
breadcrumb=f"{root}{CRUMB_SEP}disconnect",
choices=choices,
)
if svc:
_cmd_mcp(session, console, ["disconnect", svc])
show_section_break = True
if show_section_break:
repl_section_break(console)
_INTEGRATIONS_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("list", "list all configured integrations"),
("ls", "alias for list"),
("verify", "run health checks on all integrations"),
("show", "show details for a single integration"),
)
_MCP_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("list", "list connected MCP servers"),
("ls", "alias for list"),
("connect", "add an MCP server via opensre integrations setup"),
("disconnect", "remove an MCP server"),
)
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/verify",
"Verify configured integration connectivity.",
_cmd_verify,
usage=("/verify", "/verify <service>"),
),
SlashCommand(
"/integrations",
"Manage integrations.",
_cmd_integrations,
usage=(
"/integrations",
"/integrations list",
"/integrations verify",
"/integrations verify <service>",
"/integrations show <service>",
),
notes=("In a TTY, bare /integrations opens an interactive menu.",),
first_arg_completions=_INTEGRATIONS_FIRST_ARGS,
),
SlashCommand(
"/mcp",
"Manage MCP servers.",
_cmd_mcp,
usage=("/mcp", "/mcp list", "/mcp connect", "/mcp disconnect"),
notes=("In a TTY, bare /mcp opens an interactive menu.",),
first_arg_completions=_MCP_FIRST_ARGS,
),
]
__all__ = ["COMMANDS"]
@@ -0,0 +1,533 @@
"""Slash commands: /investigate, /template, /last, /save."""
from __future__ import annotations
import json
from pathlib import Path
from rich.console import Console
from rich.markup import escape
from config.llm_reasoning_effort import apply_reasoning_effort
from core.agent_harness.session.terminal_access import (
background_mode_enabled,
session_terminal,
)
from platform.common.task_types import TaskRecord
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.runtime.background.runner import (
start_background_template_investigation,
start_background_text_investigation,
)
from surfaces.interactive_shell.ui import (
DIM,
ERROR,
HIGHLIGHT,
print_repl_json,
)
from surfaces.interactive_shell.ui.components.choice_menu import (
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
from surfaces.interactive_shell.ui.foreground_investigation import run_foreground_investigation
from surfaces.interactive_shell.ui.investigation_outcome import (
InvestigationOutcome,
normalize_investigation_target,
)
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
from surfaces.interactive_shell.utils.telemetry.investigation_analytics import (
publish_investigation_outcome_analytics,
)
from surfaces.interactive_shell.utils.telemetry.turn_outcome import (
format_investigation_outcome,
format_investigation_terminal_outcome,
)
def _interactive_template_menu(session: Session, console: Console) -> bool:
from surfaces.cli.constants import ALERT_TEMPLATE_CHOICES
root = "/template"
choices: list[tuple[str, str]] = [(c, c) for c in ALERT_TEMPLATE_CHOICES]
choices.append(("done", "done"))
while True:
name = repl_choose_one(
title="template",
breadcrumb=root,
choices=choices,
)
if name is None or name == "done":
return True
_cmd_template(session, console, [name])
repl_section_break(console)
def _queue_investigate_target(session: Session, target: str) -> None:
"""Defer a menu selection to a normal ``/investigate <target>`` turn.
The interactive picker needs exclusive stdin, but long-running RCA must not
hold it — queue the resolved target so the loop auto-submits it on the next
prompt iteration without ``queue.join()`` blocking.
"""
session.terminal.set_auto_command(f"/investigate {target}")
def _interactive_investigate_menu(session: Session, console: Console) -> bool:
from surfaces.cli.constants import SAMPLE_ALERT_OPTIONS
root = "/investigate"
choices: list[tuple[str, str]] = [
("alert.json", "alert.json (bundled demo alert file)"),
]
choices.extend(SAMPLE_ALERT_OPTIONS)
choices.append(("__browse__", "custom file path…"))
choices.append(("done", "done"))
while True:
target = repl_choose_one(
title="investigate",
breadcrumb=root,
choices=choices,
)
if target is None or target == "done":
return True
if target == "__browse__":
custom_path = _prompt_investigate_path(console)
if custom_path is None:
continue
target = custom_path
_queue_investigate_target(session, target)
return True
def _prompt_investigate_path(console: Console) -> str | None:
"""Prompt for a user-supplied alert path from the investigate picker."""
console.print()
console.print(
f"[{DIM}]Enter a local alert file path (.json/.md/.txt). Use absolute or relative path.[/]"
)
try:
value = console.input(f"[{HIGHLIGHT}]file path> [/]").strip()
except (EOFError, KeyboardInterrupt):
return None
return value if value else None
def _cmd_template(session: Session, console: Console, args: list[str]) -> bool:
from surfaces.cli.constants import ALERT_TEMPLATE_CHOICES
from tools.investigation.alert_templates import build_alert_template
if not args and repl_tty_interactive():
return _interactive_template_menu(session, console)
if not args:
console.print(
f"[{DIM}]usage:[/] /template <type> (choices: {', '.join(ALERT_TEMPLATE_CHOICES)})"
)
return True
template_name = args[0].lower()
try:
payload = build_alert_template(template_name)
except ValueError:
console.print(
f"[{ERROR}]unknown template:[/] {escape(template_name)} "
f"(choices: {', '.join(ALERT_TEMPLATE_CHOICES)})"
)
return True
print_repl_json(console, json.dumps(payload, indent=2))
return True
def _validate_investigate_args(args: list[str]) -> str | None:
if not args and repl_tty_interactive():
return None
if not args:
return (
f"[{DIM}]usage:[/] /investigate <file|template> "
f"(e.g. /investigate alert.json or /investigate generic)"
)
return None
def _validate_save_args(args: list[str]) -> str | None:
if not args:
return f"[{DIM}]usage:[/] /save <path> (e.g. /save report.md or /save out.json)"
return None
def _stage_investigation_turn_telemetry(session: Session, outcome: InvestigationOutcome) -> None:
"""Stage LLM run metadata and structured errors for this turn's recorder flush."""
from core.agent_harness.accounting.token_accounting import LlmRunInfo, record_llm_turn
if outcome.llm_input_tokens or outcome.llm_output_tokens:
record_llm_turn(
session,
prompt="",
response="",
input_tokens=outcome.llm_input_tokens,
output_tokens=outcome.llm_output_tokens,
)
terminal = session_terminal(session)
if terminal is None:
return
if outcome.llm_model or outcome.llm_input_tokens or outcome.llm_output_tokens:
terminal.set_pending_turn_llm(
LlmRunInfo(
model=outcome.llm_model or None,
provider=outcome.llm_provider or None,
latency_ms=outcome.duration_ms or None,
input_tokens=outcome.llm_input_tokens or None,
output_tokens=outcome.llm_output_tokens or None,
)
)
if outcome.status == "failed":
terminal.set_pending_turn_error(
outcome.failure_category or "unknown",
outcome.error_message or "investigation failed",
)
def _record_investigation_turn(
session: Session,
*,
command_line: str,
outcome: InvestigationOutcome,
) -> None:
ok = outcome.status == "completed"
response_text = format_investigation_terminal_outcome(
command_line,
target=outcome.target,
ok=ok,
final_state=outcome.final_state,
error_message=outcome.error_message,
status=outcome.status,
)
session.record(
"alert",
command_line,
ok=ok,
response_text=response_text,
)
if not ok:
session.mark_latest(ok=False, kind="slash")
if outcome.investigation_id:
session.last_investigation_id = outcome.investigation_id
_stage_investigation_turn_telemetry(session, outcome)
publish_investigation_outcome_analytics(outcome)
def _cmd_investigate_file(session: Session, console: Console, args: list[str]) -> bool:
from platform.analytics.cli import track_investigation
from platform.analytics.source import EntrypointSource, TriggerMode
from surfaces.cli.constants import ALERT_TEMPLATE_CHOICES
from surfaces.cli.investigation.payload import resolve_alert_path
from surfaces.interactive_shell.runtime.investigation_adapter import (
run_investigation_for_session,
run_sample_alert_for_session,
)
if not args and repl_tty_interactive():
return _interactive_investigate_menu(session, console)
if not args:
console.print(
f"[{DIM}]usage:[/] /investigate <file|template> "
f"(e.g. /investigate alert.json or /investigate generic)"
)
session.mark_latest(ok=False, kind="slash")
return True
raw_target = args[0]
normalized_target = raw_target.strip().lower()
template_name = normalized_target
for prefix in ("sample:", "template:"):
if template_name.startswith(prefix):
template_name = template_name[len(prefix) :].strip()
break
if template_name not in ALERT_TEMPLATE_CHOICES:
template_name = ""
# Treat canonical template names as templates even if same-named files exist
# in the working directory. Users can still force file mode with an explicit
# path form (for example: ``/investigate ./generic``).
if template_name:
target_slug = normalize_investigation_target(template_name)
if background_mode_enabled(session):
start_background_template_investigation(
template_name=template_name,
session=session,
console=console,
display_command=f"/investigate {template_name}",
investigation_target=target_slug,
)
session.record(
"alert",
f"/investigate {template_name}",
response_text=format_investigation_outcome(
target_slug,
background=True,
),
)
return True
def _run_template(task: TaskRecord) -> dict[str, object]:
with (
apply_reasoning_effort(session.reasoning_effort),
track_investigation(
entrypoint=EntrypointSource.CLI_REPL_FILE,
trigger_mode=TriggerMode.FILE,
input_path=f"template:{template_name}",
interactive=True,
session=session,
investigation_target=target_slug,
) as tracker,
):
final_state = run_sample_alert_for_session(
template_name=template_name,
context_overrides=session.accumulated_context or None,
cancel_requested=task.cancel_requested,
)
tracker.record_loop_metrics_from_state(final_state)
return final_state
command_line = f"/investigate {template_name}"
outcome = run_foreground_investigation(
session=session,
console=console,
task_command=command_line,
run=_run_template,
exception_context="surfaces.interactive_shell.investigate_template",
target=target_slug,
)
_record_investigation_turn(session, command_line=command_line, outcome=outcome)
return True
path = resolve_alert_path(raw_target)
if not path.exists():
console.print(f"[{ERROR}]file not found:[/] {escape(str(path))}")
session.mark_latest(ok=False, kind="slash")
return True
try:
text = path.read_text(encoding="utf-8")
except Exception as exc:
report_exception(exc, context="surfaces.interactive_shell.investigate_file.read")
console.print(f"[{ERROR}]cannot read file:[/] {escape(str(exc))}")
session.mark_latest(ok=False, kind="slash")
return True
if background_mode_enabled(session):
target_slug = normalize_investigation_target(raw_target, path=path)
start_background_text_investigation(
alert_text=text,
session=session,
console=console,
display_command=f"/investigate {path}",
investigation_target=target_slug,
)
session.record(
"alert",
args[0],
response_text=format_investigation_outcome(target_slug, background=True),
)
return True
target_slug = normalize_investigation_target(raw_target, path=path)
def _run_file(task: TaskRecord) -> dict[str, object]:
with (
apply_reasoning_effort(session.reasoning_effort),
track_investigation(
entrypoint=EntrypointSource.CLI_REPL_FILE,
trigger_mode=TriggerMode.FILE,
input_path=str(path),
interactive=True,
session=session,
investigation_target=target_slug,
) as tracker,
):
final_state = run_investigation_for_session(
alert_text=text,
context_overrides=session.accumulated_context or None,
cancel_requested=task.cancel_requested,
)
tracker.record_loop_metrics_from_state(final_state)
return final_state
command_line = f"/investigate {raw_target}"
outcome = run_foreground_investigation(
session=session,
console=console,
task_command=f"/investigate {path}",
run=_run_file,
exception_context="surfaces.interactive_shell.investigate_file",
target=target_slug,
)
_record_investigation_turn(session, command_line=command_line, outcome=outcome)
return True
def _cmd_last(session: Session, console: Console, _args: list[str]) -> bool:
if session.last_state is None:
console.print(f"[{DIM}]no investigation in this session yet.[/]")
return True
root_cause = session.last_state.get("root_cause", "")
report = session.last_state.get("problem_md") or session.last_state.get("slack_message") or ""
if not root_cause and not report:
console.print(f"[{DIM}]last investigation has no report content.[/]")
return True
render_investigation_report(
console,
root_cause=str(root_cause),
report=str(report),
)
return True
def render_investigation_report(
console: Console,
*,
root_cause: str,
report: str,
) -> None:
"""Render root cause and report sections shared by /last and /rca show."""
from rich.markdown import Markdown
from rich.padding import Padding
from rich.rule import Rule
for title, body in (("Root Cause", root_cause), ("Report", report)):
if not body:
continue
console.print()
console.print(Rule(f"[bold {HIGHLIGHT}] {title} [/]", style=DIM, align="left"))
console.print(Padding(Markdown(str(body).strip()), (1, 2)))
def write_investigation_export(
dest: Path,
*,
root_cause: str = "",
report: str = "",
full_state: dict[str, object] | None = None,
) -> None:
"""Write investigation content to ``dest`` as markdown or JSON."""
if full_state is not None:
if not root_cause:
root_cause = str(full_state.get("root_cause") or "")
if not report:
report = str(
full_state.get("problem_md")
or full_state.get("slack_message")
or full_state.get("report")
or ""
)
if dest.suffix.lower() == ".json":
payload = dict(full_state) if full_state is not None else {}
if root_cause:
payload.setdefault("root_cause", root_cause)
if report:
payload.setdefault("problem_md", report)
payload.setdefault("report", report)
dest.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
return
lines: list[str] = []
if root_cause:
lines.append(f"## Root Cause\n\n{root_cause}\n")
if report:
lines.append(f"## Report\n\n{report}\n")
dest.write_text("\n".join(lines) or "(no report content)", encoding="utf-8")
def _cmd_save(session: Session, console: Console, args: list[str]) -> bool:
if session.last_state is None:
console.print(f"[{DIM}]nothing to save — run an investigation first.[/]")
return True
dest = Path(args[0])
try:
write_investigation_export(dest, full_state=session.last_state)
console.print(f"[{HIGHLIGHT}]saved:[/] {escape(str(dest))}")
except Exception as exc:
report_exception(exc, context="surfaces.interactive_shell.save_report")
console.print(f"[{ERROR}]save failed:[/] {escape(str(exc))}")
return True
_TEMPLATE_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("generic", "generic alert JSON template"),
("datadog", "Datadog monitor alert template"),
("grafana", "Grafana alert template"),
("honeycomb", "Honeycomb trigger template"),
("coralogix", "Coralogix alert template"),
("splunk", "Splunk alert template"),
)
_INVESTIGATE_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("alert.json", "run bundled demo alert file"),
("generic", "run generic sample alert"),
("datadog", "run Datadog sample alert"),
("grafana", "run Grafana sample alert"),
("honeycomb", "run Honeycomb sample alert"),
("coralogix", "run Coralogix sample alert"),
("splunk", "run Splunk sample alert"),
)
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/template",
"Print a starter alert JSON template.",
_cmd_template,
usage=(
"/template",
"/template generic",
"/template datadog",
"/template grafana",
"/template honeycomb",
"/template coralogix",
"/template splunk",
),
notes=("In a TTY, bare /template opens an interactive menu.",),
first_arg_completions=_TEMPLATE_FIRST_ARGS,
),
SlashCommand(
"/investigate",
"Run an RCA investigation from a file or sample template.",
_cmd_investigate_file,
usage=(
"/investigate <file|template>",
"/investigate alert.json",
"/investigate generic",
),
notes=(
"In a TTY, bare /investigate opens runnable demo/template options.",
"Menu selections queue a normal /investigate <target> turn so the prompt "
"stays free during RCA.",
),
first_arg_completions=_INVESTIGATE_FIRST_ARGS,
validate_args=_validate_investigate_args,
),
SlashCommand(
"/last",
"Reprint the most recent investigation report.",
_cmd_last,
),
SlashCommand(
"/save",
"Save the last investigation to a file.",
_cmd_save,
usage=("/save <path>",),
validate_args=_validate_save_args,
),
]
__all__ = ["COMMANDS"]
@@ -0,0 +1,23 @@
"""Slash command /model package exports."""
from __future__ import annotations
from surfaces.interactive_shell.command_registry.model.command import (
COMMANDS,
parse_model_set_args,
)
from surfaces.interactive_shell.command_registry.model.switching import (
restore_default_model,
switch_llm_provider,
switch_reasoning_model,
switch_toolcall_model,
)
__all__ = [
"COMMANDS",
"parse_model_set_args",
"restore_default_model",
"switch_llm_provider",
"switch_reasoning_model",
"switch_toolcall_model",
]
@@ -0,0 +1,403 @@
"""Slash command /model and interactive provider/model menus."""
from __future__ import annotations
import os
from rich.console import Console
from rich.markup import escape
import surfaces.interactive_shell.command_registry.repl_data as repl_data
from surfaces.interactive_shell.command_registry.model.switching import (
_provider_allows_custom_models,
restore_default_model,
switch_llm_provider,
switch_reasoning_model,
switch_toolcall_model,
)
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING, render_models_table
from surfaces.interactive_shell.ui.components.choice_menu import (
CRUMB_SEP,
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
_ROOT = "/model" # breadcrumb root label
def _provider_menu_choices() -> list[tuple[str, str]]:
from surfaces.cli.wizard.config import SUPPORTED_PROVIDERS
current_provider = (os.getenv("LLM_PROVIDER", "anthropic") or "anthropic").strip().lower()
options: list[tuple[str, str]] = []
for provider in SUPPORTED_PROVIDERS:
suffix = "*" if provider.value == current_provider else ""
options.append((provider.value, f"{provider.value}{suffix}"))
return options
def _reasoning_model_menu_choices(provider: object) -> list[tuple[str, str]]:
model_options = list(getattr(provider, "models", ()))
choices: list[tuple[str, str]] = [
("__provider_default__", "provider default (one step)"),
]
for option in model_options:
value = str(getattr(option, "value", ""))
display = value if value else "cli-default"
choices.append((value, display))
if _provider_allows_custom_models(provider):
choices.append(("__custom__", "custom model ID"))
return choices
def _toolcall_model_menu_choices(provider: object) -> list[tuple[str, str]]:
model_options = list(getattr(provider, "models", ()))
choices: list[tuple[str, str]] = [
("__keep__", "keep"),
("__match_reasoning__", "match-reasoning"),
]
for option in model_options:
value = str(getattr(option, "value", ""))
display = value if value else "cli-default"
choices.append((value, display))
if _provider_allows_custom_models(provider):
choices.append(("__custom__", "custom model ID"))
return choices
def _prompt_custom_model_id(console: Console, provider_value: str = "provider") -> str | None:
"""Prompt the user to type a custom model ID."""
console.print()
console.print(
f"[{DIM}]Enter a model ID for {escape(provider_value)}. "
"The provider will validate availability when OpenSRE sends a request.[/]"
)
try:
value = console.input(f"[{HIGHLIGHT}]model ID> [/]").strip()
except (EOFError, KeyboardInterrupt):
return None
return value if value else None
def _interactive_set_provider(console: Console) -> bool | None:
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
crumb_set = f"{_ROOT}{CRUMB_SEP}set"
while True:
provider_value = repl_choose_one(
title="LLM provider",
breadcrumb=crumb_set,
choices=_provider_menu_choices(),
)
if provider_value is None:
return None
provider = PROVIDER_BY_VALUE.get(provider_value)
if provider is None:
return False
crumb_model = f"{crumb_set}{CRUMB_SEP}{provider_value}"
while True:
reasoning_choice = repl_choose_one(
title="reasoning model",
breadcrumb=crumb_model,
choices=_reasoning_model_menu_choices(provider),
)
if reasoning_choice is None:
break
if reasoning_choice == "__custom__":
custom = _prompt_custom_model_id(console, provider.value)
if custom is None:
continue
reasoning_choice = custom
model_choice = (
None if reasoning_choice == "__provider_default__" else str(reasoning_choice)
)
toolcall_model: str | None = None
# Default reasoning: switch provider + default reasoning only — do not
# prompt for toolcall (matches non-interactive `/model set <provider>`).
if provider.toolcall_model_env and reasoning_choice != "__provider_default__":
crumb_tc = f"{crumb_model}{CRUMB_SEP}toolcall"
while True:
toolcall_value = repl_choose_one(
title="toolcall model",
breadcrumb=crumb_tc,
choices=_toolcall_model_menu_choices(provider),
)
if toolcall_value is None:
return None
if toolcall_value == "__keep__":
break
if toolcall_value == "__match_reasoning__":
toolcall_model = model_choice or provider.default_model
break
if toolcall_value == "__custom__":
custom_tc = _prompt_custom_model_id(console, provider.value)
if custom_tc is None:
continue
toolcall_model = custom_tc
break
toolcall_model = str(toolcall_value)
break
return switch_llm_provider(
provider.value,
console,
model=model_choice,
toolcall_model=toolcall_model,
)
def _interactive_restore_provider(console: Console) -> bool | None:
provider_value = repl_choose_one(
title="LLM provider",
breadcrumb=f"{_ROOT}{CRUMB_SEP}restore",
choices=_provider_menu_choices(),
)
if provider_value is None:
return None
return restore_default_model(provider_value, console)
def _interactive_set_toolcall(console: Console) -> bool | None:
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
crumb_tc = f"{_ROOT}{CRUMB_SEP}toolcall"
provider_value = repl_choose_one(
title="LLM provider",
breadcrumb=crumb_tc,
choices=_provider_menu_choices(),
)
if provider_value is None:
return None
provider = PROVIDER_BY_VALUE.get(provider_value)
if provider is None:
return False
if not provider.toolcall_model_env:
console.print(
f"[{WARNING}]provider {provider.value} does not expose a separate "
"toolcall model[/] — nothing to set."
)
return False
model_value = repl_choose_one(
title="toolcall model",
breadcrumb=f"{crumb_tc}{CRUMB_SEP}{provider_value}",
choices=_toolcall_model_menu_choices(provider),
)
if model_value is None:
return None
if model_value == "__keep__":
console.print("[dim]toolcall model left unchanged.[/dim]")
return True
if model_value == "__match_reasoning__":
reasoning = (os.getenv(provider.model_env, "") or "").strip() or provider.default_model
return switch_toolcall_model(reasoning, console, provider_name=provider.value)
if model_value == "__custom__":
custom_tc = _prompt_custom_model_id(console, provider.value)
if custom_tc is None:
return None
model_value = custom_tc
return switch_toolcall_model(str(model_value), console, provider_name=provider.value)
def _interactive_model_menu(session: Session, console: Console) -> bool:
while True:
action = repl_choose_one(
title="Select Model and Effort",
breadcrumb=f"{_ROOT}",
choices=[
("show", "show"),
("set", "set"),
("restore", "restore"),
("toolcall", "toolcall"),
("done", "done"),
],
)
if action is None or action == "done":
return True
if action == "show":
repl_section_break(console)
render_models_table(console, repl_data.load_llm_settings())
repl_section_break(console)
continue
if action == "set":
switched = _interactive_set_provider(console)
if switched is None:
continue
if not switched:
session.mark_latest(ok=False, kind="slash")
repl_section_break(console)
continue
return True
if action == "restore":
restored = _interactive_restore_provider(console)
if restored is None:
continue
if not restored:
session.mark_latest(ok=False, kind="slash")
repl_section_break(console)
continue
return True
if action == "toolcall":
switched = _interactive_set_toolcall(console)
if switched is None:
continue
if not switched:
session.mark_latest(ok=False, kind="slash")
repl_section_break(console)
continue
return True
def parse_model_set_args(args: list[str]) -> tuple[str, str | None, str | None]:
"""Parse `set <provider> [reasoning_model] [--toolcall-model <m>]`.
``args`` is the slice after the ``set``/``use``/``switch`` keyword.
Raises :class:`ValueError` with a user-facing message when the input is
malformed.
"""
if not args:
raise ValueError("missing provider name")
provider = args[0]
reasoning_model: str | None = None
toolcall_model: str | None = None
i = 1
while i < len(args):
token = args[i]
if token == "--toolcall-model":
if i + 1 >= len(args):
raise ValueError("missing value for --toolcall-model")
toolcall_model = args[i + 1]
i += 2
continue
if token.startswith("--"):
raise ValueError(f"unknown flag: {token}")
if reasoning_model is not None:
raise ValueError(f"unexpected extra argument: {token}")
reasoning_model = token
i += 1
return provider, reasoning_model, toolcall_model
def _cmd_model(session: Session, console: Console, args: list[str]) -> bool:
if not args and repl_tty_interactive():
return _interactive_model_menu(session, console)
sub = (args[0].lower() if args else "show").strip()
if sub == "show":
render_models_table(console, repl_data.load_llm_settings())
return True
if sub == "toolcall":
if len(args) >= 2 and args[1].lower() == "show":
render_models_table(console, repl_data.load_llm_settings())
return True
if len(args) >= 2 and args[1].lower() in ("set", "use", "switch"):
if len(args) < 3:
console.print(f"[{DIM}]usage:[/] /model toolcall set <model>")
return True
switch_toolcall_model(args[2], console)
return True
console.print(
f"[{DIM}]usage:[/] /model toolcall set <model> "
f"[{DIM}](sets the toolcall model for the active provider)[/]"
)
return True
if sub in ("restore", "default", "reset"):
if len(args) > 2:
console.print(f"[{DIM}]usage:[/] /model restore [provider]")
session.mark_latest(ok=False, kind="slash")
return True
provider_name = args[1] if len(args) == 2 else os.getenv("LLM_PROVIDER", "anthropic")
restored = restore_default_model(provider_name, console)
if not restored:
session.mark_latest(ok=False, kind="slash")
return True
if sub in ("set", "use", "switch"):
try:
provider_name, reasoning_model, tc_model = parse_model_set_args(args[1:])
except ValueError as exc:
console.print()
console.print(f"[{ERROR}]{escape(str(exc))}[/]")
console.print()
console.print(
f"[{DIM}]usage:[/] /model set <provider> [model] [--toolcall-model <model>]"
)
session.mark_latest(ok=False, kind="slash")
return True
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
if provider_name.strip().lower() not in PROVIDER_BY_VALUE:
if tc_model is not None:
console.print()
console.print(f"[{ERROR}]--toolcall-model requires an explicit provider[/]")
console.print()
console.print(
f"[{DIM}]usage:[/] /model set <provider> [model] [--toolcall-model <model>]"
)
session.mark_latest(ok=False, kind="slash")
return True
model_value = (
provider_name if reasoning_model is None else f"{provider_name}-{reasoning_model}"
)
switched = switch_reasoning_model(model_value, console)
if not switched:
session.mark_latest(ok=False, kind="slash")
return True
switched = switch_llm_provider(
provider_name,
console,
model=reasoning_model,
toolcall_model=tc_model,
)
if not switched:
session.mark_latest(ok=False, kind="slash")
return True
console.print(
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
"(try [bold]/model show[/bold], "
"[bold]/model set <provider> [model] [--toolcall-model <m>][/bold], "
"[bold]/model restore [provider][/bold], "
"or [bold]/model toolcall set <model>[/bold])"
)
return True
_MODEL_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("show", "show active provider and models"),
("set", "switch provider · /model set <provider> [model]"),
("restore", "restore the active provider's default reasoning model"),
("toolcall", "manage toolcall model for the active provider"),
)
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/model",
"Show or change active LLM settings.",
_cmd_model,
usage=(
"/model",
"/model show",
"/model set <provider> [model] [--toolcall-model <model>]",
"/model restore [provider]",
"/model toolcall set <model>",
),
notes=(
"In a TTY, bare /model opens an interactive menu.",
"The menu stays open after show actions and closes after set, restore, or toolcall changes.",
),
first_arg_completions=_MODEL_FIRST_ARGS,
),
]
@@ -0,0 +1,298 @@
"""Provider/model switch helpers for the /model slash command."""
from __future__ import annotations
import os
from rich.console import Console
from rich.markup import escape
import surfaces.interactive_shell.command_registry.repl_data as repl_data
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING, render_models_table
from surfaces.interactive_shell.ui.components.choice_menu import print_valid_choice_list
def _format_supported_models(provider_models: tuple[object, ...]) -> str:
values = [str(getattr(model, "value", "")) for model in provider_models]
visible = [value for value in values if value]
return ", ".join(visible) if visible else "provider default"
def _normalize_model_id(model: str) -> str:
"""Collapse internal whitespace in a model id to single hyphens.
A model id is a single token, so a value like ``"gpt 5.5"`` is a mis-parsed
``"gpt-5.5"``. The CLI path (``/model set gpt 5.5``) already rebuilds the id as
``gpt-5.5``; normalizing here keeps the planner/tool path
(``llm_set_provider`` -> ``switch_reasoning_model``) consistent so a custom-model
provider (e.g. openai) can't persist a whitespace-bearing slug that later fails
availability checks and silently falls back.
"""
return "-".join(model.split())
def _is_model_supported(
_provider_value: str, model: str, provider_models: tuple[object, ...]
) -> bool:
supported_values = {str(getattr(option, "value", "")) for option in provider_models}
return model in supported_values
def _provider_allows_custom_models(provider: object) -> bool:
return bool(getattr(provider, "allow_custom_models", False))
def _is_model_allowed(provider: object, model: str) -> bool:
provider_value = str(getattr(provider, "value", ""))
provider_models = getattr(provider, "models", ())
if _is_model_supported(provider_value, model, provider_models):
return True
return bool(model) and _provider_allows_custom_models(provider)
def _reset_runtime_llm_caches() -> None:
"""Force subsequent REPL assistant calls to use the updated model env."""
from core.llm.factory import reset_llm_clients
reset_llm_clients()
def switch_llm_provider(
provider_name: str,
console: Console,
model: str | None = None,
*,
toolcall_model: str | None = None,
) -> bool:
from config.llm_auth.credentials import status as credential_status
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
from surfaces.cli.wizard.env_sync import sync_provider_env
provider_key = provider_name.strip().lower()
provider = PROVIDER_BY_VALUE.get(provider_key)
if provider is None:
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(provider_name)}")
print_valid_choice_list(
console,
title="valid providers:",
choices=sorted(PROVIDER_BY_VALUE),
)
return False
# Refuse to half-update .env when prompt-safe status says the target
# provider has no credential path. Stale metadata gets a warning, because
# confirming it requires an intentional request-time credential read.
auth_status = credential_status(provider.value)
if provider.value == "azure-openai":
from core.llm.providers.azure_openai import azure_openai_endpoint_configured
if not azure_openai_endpoint_configured():
console.print(
f"[{ERROR}]missing Azure OpenAI endpoint config:[/] "
"set AZURE_OPENAI_BASE_URL, or run [bold]opensre onboard[/bold]."
)
return False
if provider.credential_secret and provider.api_key_env and not auth_status.configured:
console.print(
f"[{ERROR}]missing credential for {provider.value}:[/] "
f"{provider.api_key_env} is not set."
)
if not getattr(console, "is_terminal", False):
# Non-interactive (script/headless): no stdin to prompt on.
console.print(
f"[{DIM}]set it with[/] [bold]export {provider.api_key_env}=<your-key>[/bold] "
f"[{DIM}]or run[/] [bold]opensre auth login {provider.value}[/bold] "
f"[{DIM}]to save it, then rerun this command.[/]"
)
return False
api_key = console.input(
f"[{HIGHLIGHT}]paste your {provider.api_key_env} (blank to cancel)> [/]",
password=True,
).strip()
if not api_key:
console.print(
f"[{DIM}]cancelled — set it later with[/] "
f"[bold]opensre auth login {provider.value}[/bold][{DIM}].[/]"
)
return False
from surfaces.cli.llm_auth.providers import resolve_auth_profile
from surfaces.cli.llm_auth.service import AuthSetupError, configure_api_key_provider
console.print(f"[{DIM}]validating {provider.value} key…[/]")
try:
configure_api_key_provider(
profile=resolve_auth_profile(provider.value),
api_key=api_key,
set_provider=False,
)
except (AuthSetupError, KeyError) as exc:
console.print(f"[{ERROR}]could not save {provider.api_key_env}:[/] {escape(str(exc))}")
return False
console.print(f"[{DIM}]saved {provider.api_key_env}.[/]")
auth_status = credential_status(provider.value)
if provider.credential_secret and provider.api_key_env and auth_status.stale:
console.print(
f"[{WARNING}]credential status for {provider.value} is stale:[/] "
f"{escape(auth_status.detail)}"
)
console.print(
f"[{DIM}]run[/] [bold]opensre auth verify {provider.value}[/bold] "
f"[{DIM}]to refresh metadata if the next LLM request fails.[/]"
)
selected_model = _normalize_model_id(model) if model else provider.default_model
if selected_model and not _is_model_allowed(provider, selected_model):
console.print(f"[{ERROR}]unknown model for {provider.value}:[/] {escape(selected_model)}")
console.print(
f"[{DIM}]known reasoning models:[/] {escape(_format_supported_models(provider.models))}"
)
return False
selected_toolcall: str | None = None
if toolcall_model is not None:
if not provider.toolcall_model_env:
console.print(
f"[{WARNING}]provider {provider.value} does not expose a separate "
"toolcall model[/] — toolcall override ignored."
)
else:
selected_toolcall = _normalize_model_id(toolcall_model)
if selected_toolcall and not _is_model_allowed(provider, selected_toolcall):
console.print(
f"[{ERROR}]unknown model for {provider.value}:[/] {escape(selected_toolcall)}"
)
console.print(
f"[{DIM}]known toolcall models:[/] "
f"{escape(_format_supported_models(provider.models))}"
)
return False
env_path = sync_provider_env(
provider=provider,
model=selected_model,
toolcall_model=selected_toolcall or None,
)
_reset_runtime_llm_caches()
# Be explicit about which slot each model lands in.
console.print(f"[{HIGHLIGHT}]switched LLM provider:[/] {provider.value}")
console.print(
f"[{HIGHLIGHT}]reasoning model:[/] {selected_model or 'provider default'} "
f"[{DIM}]({provider.model_env})[/]"
)
if selected_toolcall:
console.print(
f"[{HIGHLIGHT}]toolcall model:[/] {selected_toolcall} "
f"[{DIM}]({provider.toolcall_model_env})[/]"
)
console.print(f"[{DIM}]updated {env_path}[/]")
render_models_table(console, repl_data.load_llm_settings())
return True
def switch_toolcall_model(
toolcall_model: str,
console: Console,
*,
provider_name: str | None = None,
) -> bool:
"""Set the toolcall model for the active (or named) provider."""
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
from surfaces.cli.wizard.env_sync import sync_env_values
raw_name = provider_name if provider_name else os.getenv("LLM_PROVIDER", "anthropic")
resolved_name = (raw_name or "anthropic").strip().lower()
provider = PROVIDER_BY_VALUE.get(resolved_name)
if provider is None:
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(resolved_name)}")
print_valid_choice_list(
console,
title="valid providers:",
choices=sorted(PROVIDER_BY_VALUE),
)
return False
if not provider.toolcall_model_env:
console.print(
f"[{WARNING}]provider {provider.value} does not expose a separate "
"toolcall model[/] — nothing to set."
)
return False
new_model = _normalize_model_id(toolcall_model)
if not new_model:
console.print(f"[{ERROR}]toolcall model cannot be empty[/]")
return False
values = {provider.toolcall_model_env: new_model}
env_path = sync_env_values(values)
os.environ.update(values)
_reset_runtime_llm_caches()
console.print(
f"[{HIGHLIGHT}]toolcall model set to:[/] {new_model} "
f"[{DIM}]({provider.value} · {provider.toolcall_model_env})[/]"
)
console.print(f"[{DIM}]updated {env_path}[/]")
render_models_table(console, repl_data.load_llm_settings())
return True
def switch_reasoning_model(
reasoning_model: str,
console: Console,
*,
provider_name: str | None = None,
) -> bool:
"""Set the reasoning model for the active (or named) provider."""
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
from surfaces.cli.wizard.env_sync import sync_reasoning_model_env
raw_name = provider_name if provider_name else os.getenv("LLM_PROVIDER", "anthropic")
resolved_name = (raw_name or "anthropic").strip().lower()
provider = PROVIDER_BY_VALUE.get(resolved_name)
if provider is None:
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(resolved_name)}")
print_valid_choice_list(
console,
title="valid providers:",
choices=sorted(PROVIDER_BY_VALUE),
)
return False
new_model = _normalize_model_id(reasoning_model)
if not new_model:
console.print(f"[{ERROR}]reasoning model cannot be empty[/]")
return False
if not _is_model_allowed(provider, new_model):
console.print(f"[{ERROR}]unknown model for {provider.value}:[/] {escape(new_model)}")
console.print(
f"[{DIM}]known reasoning models:[/] {escape(_format_supported_models(provider.models))}"
)
return False
env_path = sync_reasoning_model_env(provider=provider, model=new_model)
_reset_runtime_llm_caches()
console.print(
f"[{HIGHLIGHT}]reasoning model set to:[/] {new_model} "
f"[{DIM}]({provider.value} · {provider.model_env})[/]"
)
console.print(f"[{DIM}]updated {env_path}[/]")
render_models_table(console, repl_data.load_llm_settings())
return True
def restore_default_model(provider_name: str, console: Console) -> bool:
"""Reset a provider to its configured default reasoning model."""
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
provider_key = provider_name.strip().lower()
provider = PROVIDER_BY_VALUE.get(provider_key)
if provider is None:
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(provider_name)}")
print_valid_choice_list(
console,
title="valid providers:",
choices=sorted(PROVIDER_BY_VALUE),
)
return False
return switch_llm_provider(provider.value, console, model=provider.default_model)
@@ -0,0 +1,263 @@
"""Slash commands: history management and privacy controls (/history, /privacy)."""
from __future__ import annotations
from prompt_toolkit.history import FileHistory, InMemoryHistory
from rich.console import Console
from rich.markup import escape
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.prompt_history import (
clear_persisted_history,
load_command_history_entries,
prompt_history_path,
)
from surfaces.interactive_shell.prompt_history.policy import (
DEFAULT_REDACTION_RULES,
RedactingFileHistory,
)
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import (
BOLD_BRAND,
DIM,
ERROR,
HIGHLIGHT,
print_repl_table,
repl_table,
)
from surfaces.interactive_shell.ui.components.choice_menu import (
CRUMB_SEP,
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
def _show_history(console: Console) -> bool:
entries = load_command_history_entries()
if not entries:
console.print(f"[{DIM}]no history yet.[/]")
return True
table = repl_table(title="Command history\n", title_style=BOLD_BRAND)
table.add_column("#", style=DIM, justify="right")
table.add_column("text", overflow="fold")
for i, entry in enumerate(entries, start=1):
table.add_row(str(i), escape(entry))
print_repl_table(console, table)
return True
def _history_clear(session: Session, console: Console) -> bool: # noqa: ARG001
if clear_persisted_history():
console.print(
f"[{HIGHLIGHT}]cleared[/] persistent history. Up-arrow recall resets on next launch."
)
else:
console.print(
f"[{ERROR}]could not clear history[/] (file system error). "
f"path: {prompt_history_path()}"
)
return True
def _history_pause(session: Session, console: Console, *, paused: bool) -> bool:
backend = session.terminal.prompt_history_backend
if isinstance(backend, RedactingFileHistory):
backend.paused = paused
state = "off" if paused else "on"
console.print(f"[{DIM}]history persistence is now {state} for this session.[/]")
return True
if isinstance(backend, FileHistory):
if paused:
console.print(
f"[{DIM}]history is persisting to disk without redaction. "
"Restart with OPENSRE_HISTORY_REDACT=1 to enable runtime pause support, "
"or OPENSRE_HISTORY_ENABLED=0 to disable persistence entirely.[/]"
)
return True
console.print(f"[{DIM}]history persistence is already on (raw file history).[/]")
return True
if backend is None or isinstance(backend, InMemoryHistory):
if paused:
console.print(f"[{DIM}]history is already not persisting in this session.[/]")
return True
console.print(
f"[{DIM}]history is in-memory only. "
"Restart with OPENSRE_HISTORY_ENABLED=1 to enable persistence.[/]"
)
return True
if paused:
console.print(f"[{DIM}]history is already not persisting in this session.[/]")
return True
console.print(
f"[{DIM}]history backend does not support runtime persistence controls in this session.[/]"
)
return True
def _history_retention(session: Session, console: Console, args: list[str]) -> bool:
if not args:
console.print(f"[{ERROR}]usage:[/] /history retention <N>")
return True
try:
n = int(args[0])
if n < 0:
raise ValueError
except ValueError:
console.print(f"[{ERROR}]retention must be a non-negative integer[/]")
return True
backend = session.terminal.prompt_history_backend
if isinstance(backend, RedactingFileHistory):
backend.set_max_entries(n, prune=True)
console.print(
f"[{DIM}]retention cap set to {n} for this session "
"(set OPENSRE_HISTORY_MAX_ENTRIES or interactive.history.max_entries to persist).[/]"
)
return True
console.print(
f"[{DIM}]retention applies only when redacting persistent history. "
"Restart with OPENSRE_HISTORY_REDACT=1 to enable.[/]"
)
return True
def _interactive_history_menu(session: Session, console: Console) -> bool:
root = "/history"
while True:
sub = repl_choose_one(
title="history",
breadcrumb=root,
choices=[
("show", "show"),
("clear", "clear"),
("off", "off"),
("on", "on"),
("retention", "retention"),
("done", "done"),
],
)
if sub is None or sub == "done":
return True
show_section_break = False
if sub == "show":
_show_history(console)
show_section_break = True
elif sub == "clear":
_history_clear(session, console)
show_section_break = True
elif sub == "off":
_history_pause(session, console, paused=True)
show_section_break = True
elif sub == "on":
_history_pause(session, console, paused=False)
show_section_break = True
elif sub == "retention":
cap = repl_choose_one(
title="retention cap",
breadcrumb=f"{root}{CRUMB_SEP}retention",
choices=[
("100", "100"),
("500", "500"),
("1000", "1000"),
("5000", "5000"),
],
)
if cap:
_history_retention(session, console, [cap])
show_section_break = True
if show_section_break:
repl_section_break(console)
def _cmd_history(session: Session, console: Console, args: list[str]) -> bool:
if not args and repl_tty_interactive():
return _interactive_history_menu(session, console)
if not args:
return _show_history(console)
sub = args[0].lower()
if sub == "clear":
return _history_clear(session, console)
if sub == "off":
return _history_pause(session, console, paused=True)
if sub == "on":
return _history_pause(session, console, paused=False)
if sub == "retention":
return _history_retention(session, console, args[1:])
console.print(f"[{ERROR}]usage:[/] /history [clear|off|on|retention <N>]")
return True
def _cmd_privacy(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
backend = session.terminal.prompt_history_backend
table = repl_table(title="Privacy settings\n", title_style=BOLD_BRAND, show_header=False)
table.add_column("setting", style="bold")
table.add_column("value")
if isinstance(backend, RedactingFileHistory):
persistence = "off (paused)" if backend.paused else "on"
redaction = "on"
retention = str(backend.max_entries) if backend.max_entries > 0 else "unlimited"
elif backend is None or isinstance(backend, InMemoryHistory):
persistence = "off (in-memory only)"
redaction = "n/a"
retention = "n/a"
elif isinstance(backend, FileHistory):
persistence = "on (no redaction)"
redaction = "off"
retention = "n/a"
else:
persistence = "unknown"
redaction = "unknown"
retention = "n/a"
table.add_row("persistence", persistence)
table.add_row("redaction", redaction)
table.add_row("retention cap", retention)
table.add_row("file", str(prompt_history_path()))
table.add_row("built-in patterns", str(len(DEFAULT_REDACTION_RULES)))
print_repl_table(console, table)
console.print(
f"[{DIM}]threat model: prompt history is stored unencrypted on disk. "
f"Use[/] [{HIGHLIGHT}]/history clear[/] [{DIM}]after sharing your machine, "
f"or[/] [{HIGHLIGHT}]/history off[/] [{DIM}]to pause this session, "
f"or set OPENSRE_HISTORY_ENABLED=0 to disable persistence entirely.[/]"
)
return True
_HISTORY_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("clear", "delete persisted history file"),
("off", "pause history persistence for this session"),
("on", "resume history persistence for this session"),
("retention", "set max entries cap (e.g. /history retention 1000)"),
)
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/history",
"Manage command history.",
_cmd_history,
usage=(
"/history",
"/history clear",
"/history off",
"/history on",
"/history retention <N>",
),
notes=("In a TTY, bare /history opens an interactive menu.",),
first_arg_completions=_HISTORY_FIRST_ARGS,
),
SlashCommand(
"/privacy",
"Show history persistence, redaction status, and threat model.",
_cmd_privacy,
),
]
__all__ = ["COMMANDS"]
@@ -0,0 +1,488 @@
"""Slash command /rca — browse persisted RCA reports across sessions."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from rich.console import Console
from rich.markup import escape
from core.agent_harness.session import default_session_repo
from surfaces.interactive_shell.command_registry.investigation import (
render_investigation_report,
write_investigation_export,
)
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import (
BOLD_BRAND,
DIM,
ERROR,
HIGHLIGHT,
WARNING,
print_repl_table,
repl_table,
)
from surfaces.interactive_shell.ui.components.choice_menu import (
CRUMB_SEP,
prepare_repl_output_line,
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
from surfaces.interactive_shell.ui.components.time_format import format_repl_timestamp
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
_RCA_ROOT = "/rca"
_RCA_LATEST = "__latest__"
_RCA_HISTORY = "__history__"
_RCA_SAVE = "__save__"
_HISTORY_ALIASES = frozenset({"history", "list", "ls"})
_EXPORT_SUFFIXES = frozenset({".md", ".json"})
def _investigation_id(record: dict[str, object]) -> str:
return str(record.get("investigation_id") or "")
def _record_timestamp(record: dict[str, object], *, style: str) -> str:
return format_repl_timestamp(record.get("completed_at"), style=style) # type: ignore[arg-type]
def _rca_breadcrumb(suffix: str) -> str:
return _RCA_ROOT if not suffix else f"{_RCA_ROOT}{CRUMB_SEP}{suffix}"
def _rca_record_label(record: dict[str, object]) -> str:
inv_id = _investigation_id(record) or ""
completed = _record_timestamp(record, style="compact")
preview = str(record.get("root_cause_preview") or "")
if len(preview) > 44:
preview = preview[:41] + ""
trigger = str(record.get("trigger") or "").strip()
trigger_part = f" {trigger[:28]}" if trigger else ""
return f"{inv_id[:8]} {completed} {preview}{trigger_part}"
def _print_rca_empty(console: Console) -> None:
console.print(f"[{DIM}]no persisted RCA reports yet.[/]")
console.print(
f"[{DIM}]run an investigation with[/] [{WARNING}]/investigate[/] "
f"[{DIM}]to populate history.[/]"
)
def _require_rca_records(console: Console) -> list[dict[str, object]] | None:
records = default_session_repo().load_investigation_history()
if not records:
_print_rca_empty(console)
return None
return records
def _rca_record_export_state(record: dict[str, object]) -> dict[str, object]:
report = str(record.get("report") or "")
return {
"investigation_id": record.get("investigation_id"),
"session_id": record.get("session_id"),
"completed_at": record.get("completed_at"),
"trigger": record.get("trigger"),
"root_cause": record.get("root_cause"),
"problem_md": report,
"report": report,
"root_cause_category": record.get("root_cause_category"),
"alert_name": record.get("alert_name"),
"run_id": record.get("run_id"),
}
def _print_rca_lookup_failure(
console: Console,
investigation_id: str,
*,
match_count: int,
) -> None:
if match_count > 1:
console.print(
f"[{WARNING}]ambiguous id prefix:[/] {escape(investigation_id)} "
f"[{DIM}]({match_count} matches — use more characters)[/]"
)
return
console.print(f"[{ERROR}]RCA report not found:[/] {escape(investigation_id)}")
def _resolve_rca_record(
investigation_id: str | None,
*,
records: list[dict[str, object]] | None = None,
) -> dict[str, object] | None:
repo = default_session_repo()
if investigation_id:
loaded = repo.load_investigation(investigation_id)
if loaded is not None:
return loaded
if records:
for record in records:
inv_id = _investigation_id(record)
if inv_id.startswith(investigation_id):
return record
return None
history = records or repo.load_investigation_history()
if not history:
return None
latest = history[0]
inv_id = _investigation_id(latest)
if not inv_id:
return latest
return repo.load_investigation(inv_id) or latest
def _strip_outer_quotes(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1].strip()
return value
def _normalize_rca_save_path(raw_path: str, *, investigation_id: str = "") -> Path:
"""Normalize user-entered save paths (strip quotes, expand ~, folder → file)."""
value = _strip_outer_quotes(raw_path.strip())
treat_as_dir = value.endswith(("/", "\\"))
dest = Path(value).expanduser()
if dest.suffix.lower() not in _EXPORT_SUFFIXES and (treat_as_dir or dest.is_dir()):
dest = dest / f"rca-{investigation_id[:8] or 'report'}.md"
return dest
def _save_rca_record(console: Console, record: dict[str, object], dest_path: str) -> bool:
inv_id = _investigation_id(record)
dest = _normalize_rca_save_path(dest_path, investigation_id=inv_id)
try:
dest.parent.mkdir(parents=True, exist_ok=True)
write_investigation_export(
dest,
root_cause=str(record.get("root_cause") or ""),
report=str(record.get("report") or ""),
full_state=_rca_record_export_state(record),
)
console.print(f"[{HIGHLIGHT}]saved:[/] {escape(str(dest))}")
except IsADirectoryError:
console.print(
f"[{ERROR}]save failed:[/] {escape(str(dest))} is a directory — "
f"include a filename (e.g. [{WARNING}]report.md[/])"
)
except Exception as exc:
report_exception(exc, context="surfaces.interactive_shell.rca_save")
console.print(f"[{ERROR}]save failed:[/] {escape(str(exc))}")
return True
def _prompt_rca_save_path(console: Console) -> str | None:
console.print()
console.print(
f"[{DIM}]Enter output file or folder (.md or .json). "
f"Example:[/] [{WARNING}]rca-report.md[/] "
f"[{DIM}]or[/] [{WARNING}]/Users/you/Downloads/rca reports/[/]"
)
try:
value = console.input(f"[{HIGHLIGHT}]file path> [/]").strip()
except (EOFError, KeyboardInterrupt):
return None
return value or None
def _report_picker_choices(
records: list[dict[str, object]],
*,
include_latest: bool,
) -> list[tuple[str, str]]:
choices: list[tuple[str, str]] = []
if include_latest:
choices.append((_RCA_LATEST, "latest"))
choices.extend(
(inv_id, _rca_record_label(record))
for record in records
if (inv_id := _investigation_id(record))
)
choices.append(("done", "done"))
return choices
def _pick_rca_report(
records: list[dict[str, object]],
*,
breadcrumb_suffix: str,
include_latest: bool = False,
) -> str | None:
picked = repl_choose_one(
title="rca report",
breadcrumb=_rca_breadcrumb(breadcrumb_suffix),
choices=_report_picker_choices(records, include_latest=include_latest),
)
if picked is None or picked == "done":
return None
return picked
def _picked_investigation_id(picked: str, records: list[dict[str, object]]) -> str:
if picked == _RCA_LATEST:
return _investigation_id(records[0])
return picked
def _interactive_rca_report_menu(
session: Session,
console: Console,
*,
breadcrumb_suffix: str,
include_latest: bool,
on_pick: Callable[[Session, Console, dict[str, object]], bool],
) -> bool:
records = _require_rca_records(console)
if records is None:
return True
picked = _pick_rca_report(
records,
breadcrumb_suffix=breadcrumb_suffix,
include_latest=include_latest,
)
if picked is None:
return True
record = _resolve_rca_record(_picked_investigation_id(picked, records), records=records)
if record is None:
console.print(f"[{DIM}]RCA report not found.[/]")
return True
return on_pick(session, console, record)
def _interactive_show_record(
session: Session,
console: Console,
record: dict[str, object],
) -> bool:
inv_id = _investigation_id(record)
if not inv_id:
return True
_cmd_rca_show(session, console, inv_id, record=record)
repl_section_break(console)
return True
def _interactive_save_record(
_session: Session,
console: Console,
record: dict[str, object],
) -> bool:
dest_path = _prompt_rca_save_path(console)
if dest_path is None:
return True
return _save_rca_record(console, record, dest_path)
def _interactive_rca_history_menu(session: Session, console: Console) -> bool:
return _interactive_rca_report_menu(
session,
console,
breadcrumb_suffix="history",
include_latest=False,
on_pick=_interactive_show_record,
)
def _interactive_rca_save_menu(session: Session, console: Console) -> bool:
return _interactive_rca_report_menu(
session,
console,
breadcrumb_suffix="save",
include_latest=True,
on_pick=_interactive_save_record,
)
def _interactive_rca_root_menu(session: Session, console: Console) -> bool:
records = _require_rca_records(console)
if records is None:
return True
picked = repl_choose_one(
title="rca report",
breadcrumb=_RCA_ROOT,
choices=[
(_RCA_LATEST, "latest"),
(_RCA_HISTORY, "history"),
(_RCA_SAVE, "save"),
("done", "done"),
],
)
if picked is None or picked == "done":
return True
if picked == _RCA_HISTORY:
return _interactive_rca_history_menu(session, console)
if picked == _RCA_SAVE:
return _interactive_rca_save_menu(session, console)
latest_id = _investigation_id(records[0])
if not latest_id:
return True
return _interactive_show_record(session, console, records[0])
def _cmd_rca_history(_session: Session, console: Console) -> bool:
records = _require_rca_records(console)
if records is None:
return True
table = repl_table(title="RCA history\n", title_style=BOLD_BRAND)
table.add_column("#", style="bold", justify="right")
table.add_column("ID", style="bold")
table.add_column("Completed")
table.add_column("Trigger", overflow="fold")
table.add_column("Root cause", overflow="fold", style=DIM)
for index, record in enumerate(records, start=1):
table.add_row(
str(index),
_investigation_id(record) or "",
_record_timestamp(record, style="table"),
escape(str(record.get("trigger") or record.get("session_name") or "")),
escape(str(record.get("root_cause_preview") or "")),
)
print_repl_table(console, table)
console.print(
f"[{DIM}]show full report:[/] [{WARNING}]/rca show <id>[/] "
f"[{DIM}]save:[/] [{WARNING}]/rca save <path>[/] "
f"[{DIM}]or[/] [{WARNING}]/rca save <id> <path>[/]"
)
return True
def _print_rca_record_header(console: Console, record: dict[str, object]) -> None:
console.print()
console.print(
f"[{DIM}]id[/] [bold]{escape(_investigation_id(record))}[/] "
f"[{DIM}]session[/] {escape(str(record.get('session_id') or '')[:8])} "
f"[{DIM}]completed[/] {escape(_record_timestamp(record, style='table'))}"
)
trigger = str(record.get("trigger") or "").strip()
if trigger:
console.print(f"[{DIM}]trigger[/] {escape(trigger)}")
def _cmd_rca_show(
_session: Session,
console: Console,
investigation_id: str,
*,
record: dict[str, object] | None = None,
) -> bool:
if record is not None:
resolved = record
else:
loaded, match_count = default_session_repo().lookup_investigation(investigation_id)
if match_count != 1:
_print_rca_lookup_failure(console, investigation_id, match_count=match_count)
return True
if loaded is None:
_print_rca_lookup_failure(console, investigation_id, match_count=0)
return True
resolved = loaded
_print_rca_record_header(console, resolved)
render_investigation_report(
console,
root_cause=str(resolved.get("root_cause") or ""),
report=str(resolved.get("report") or ""),
)
return True
def _cmd_rca_save(
_session: Session,
console: Console,
*,
investigation_id: str | None,
dest_path: str,
) -> bool:
if investigation_id:
record, match_count = default_session_repo().lookup_investigation(investigation_id)
if match_count != 1:
_print_rca_lookup_failure(console, investigation_id, match_count=match_count)
return True
if record is None:
_print_rca_lookup_failure(console, investigation_id, match_count=0)
return True
else:
record = _resolve_rca_record(None)
if record is None:
_print_rca_empty(console)
return True
return _save_rca_record(console, record, dest_path)
def _cmd_rca(_session: Session, console: Console, args: list[str]) -> bool:
prepare_repl_output_line()
if not args:
if repl_tty_interactive():
return _interactive_rca_root_menu(_session, console)
return _cmd_rca_history(_session, console)
sub = args[0].lower().strip()
if sub in _HISTORY_ALIASES:
if repl_tty_interactive():
return _interactive_rca_history_menu(_session, console)
return _cmd_rca_history(_session, console)
if sub == "show":
if len(args) < 2:
if repl_tty_interactive():
return _interactive_rca_root_menu(_session, console)
console.print(f"[{DIM}]usage:[/] /rca show <investigation-id-prefix>")
return True
return _cmd_rca_show(_session, console, args[1])
if sub == "save":
if len(args) == 1:
if repl_tty_interactive():
return _interactive_rca_save_menu(_session, console)
console.print(
f"[{DIM}]usage:[/] /rca save <path> "
f"[{DIM}]or[/] /rca save <investigation-id> <path>"
)
return True
if len(args) == 2:
return _cmd_rca_save(_session, console, investigation_id=None, dest_path=args[1])
return _cmd_rca_save(_session, console, investigation_id=args[1], dest_path=args[2])
console.print(
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
f"(try [bold]/rca history[/bold], [bold]/rca show <id>[/bold], "
f"or [bold]/rca save <path>[/bold])"
)
return True
_RCA_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("history", "list persisted RCA reports across sessions"),
("show", "show one RCA report by investigation id"),
("save", "save an RCA report to a file (.md or .json)"),
)
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/rca",
"Browse persisted RCA investigation reports.",
_cmd_rca,
usage=(
"/rca",
"/rca history",
"/rca show <investigation-id>",
"/rca save <path>",
"/rca save <investigation-id> <path>",
),
first_arg_completions=_RCA_FIRST_ARGS,
),
]
__all__ = ["COMMANDS"]
@@ -0,0 +1,48 @@
"""Lazy loaders for verified integrations and LLM settings (repl slash commands)."""
from __future__ import annotations
from typing import Any
def load_verified_integrations() -> list[dict[str, str]]:
"""Import lazily so an unconfigured store doesn't slow down every REPL turn."""
from integrations.verify import verify_integrations
return verify_integrations()
def configured_integration_names() -> list[str]:
"""Return configured integration service names without running verifiers."""
from integrations.verify import resolve_effective_integrations
return sorted(resolve_effective_integrations())
def verify_integration(service: str) -> dict[str, str] | None:
"""Verify a single integration and return its result row."""
from integrations.verify import verify_integrations
normalized = service.strip().lower()
if not normalized:
return None
rows = verify_integrations(normalized)
return rows[0] if rows else None
def load_llm_settings() -> Any | None:
"""Best-effort LLM settings load; returns None if env is misconfigured."""
try:
from config.config import LLMSettings
return LLMSettings.from_env()
except Exception:
return None
__all__ = [
"configured_integration_names",
"load_llm_settings",
"load_verified_integrations",
"verify_integration",
]
@@ -0,0 +1,58 @@
"""Session lifecycle slash commands: /clear, /new, /sessions, /resume, /compact."""
from __future__ import annotations
from surfaces.interactive_shell.command_registry.session_cmds.lifecycle import (
_cmd_clear,
_cmd_compact,
_cmd_new,
)
from surfaces.interactive_shell.command_registry.session_cmds.list import _cmd_sessions
from surfaces.interactive_shell.command_registry.session_cmds.resume import (
_apply_resume_data,
_cmd_resume,
)
from surfaces.interactive_shell.command_registry.types import SlashCommand
COMMANDS: list[SlashCommand] = [
SlashCommand("/clear", "Clear the screen and re-render the banner.", _cmd_clear),
SlashCommand(
"/sessions",
"List recent REPL sessions.",
_cmd_sessions,
usage=("/sessions",),
),
SlashCommand(
"/resume",
"Resume a previous session by restoring its conversation context.",
_cmd_resume,
usage=("/resume <session-id-prefix>", "/resume <session-id-prefix>:<entry-id-prefix>"),
notes=(
"Restores cli_agent_messages and accumulated infra context from the chosen session.",
"Bare /resume opens an interactive session picker in a TTY.",
"Accepts a session ID prefix, entry ref, or name substring (e.g. /resume redis).",
"Replaces the current session's LLM conversation context; warns if messages exist.",
),
),
SlashCommand(
"/new",
"Start a new session while keeping the current conversation context.",
_cmd_new,
notes=(
"Unlike /clear, /new rotates the session ID and resets state while keeping LLM context.",
"Use after /resume to continue a conversation in a clean session file.",
),
),
SlashCommand(
"/compact",
"Compact the current session context into a replayable summary entry.",
_cmd_compact,
usage=("/compact",),
notes=(
"Writes a compaction entry and keeps the most recent messages plus a summary.",
"Useful before continuing a long-running investigation in the same REPL.",
),
),
]
__all__ = ["COMMANDS", "_apply_resume_data"]
@@ -0,0 +1,58 @@
"""Session lifecycle slash commands: /clear, /new, and /compact."""
from __future__ import annotations
from rich.console import Console
from core.agent_harness.session import SessionManager
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import DIM, HIGHLIGHT
def _cmd_clear(session: Session, console: Console, _args: list[str]) -> bool:
from surfaces.interactive_shell.ui import render_ready_box
console.clear()
render_ready_box(console, session=session)
return True
def _cmd_new(session: Session, console: Console, _args: list[str]) -> bool:
"""Start a new session while preserving the current LLM conversation context.
Unlike /clear (which only clears the screen), /new rotates the session ID
and resets all session state while keeping cli_agent_messages and
accumulated_context so a resumed or in-progress conversation continues
seamlessly in a fresh session file.
"""
saved_messages = list(session.agent.messages)
saved_context = dict(session.accumulated_context)
saved_resumed_name = session.resumed_from_name
SessionManager.for_session(session).rotate_in_place(session)
session.agent.messages = saved_messages
session.accumulated_context = saved_context
session.resumed_from_name = saved_resumed_name
console.print(
f"[{DIM}]new session started[/] [{HIGHLIGHT}]—[/] [{DIM}]conversation context carried forward.[/]"
)
if saved_messages:
console.print(f"[{DIM}] {len(saved_messages)} messages in context · type to continue[/]")
return True
def _cmd_compact(session: Session, console: Console, _args: list[str]) -> bool:
"""Compact the live session branch and persist a compaction entry."""
from core.agent_harness.turns.transcript_compaction import compact_session_branch
result = compact_session_branch(session)
if result is None:
console.print(f"[{DIM}]Nothing to compact yet.[/]")
return True
console.print(
f"[{HIGHLIGHT}]compacted session context[/] "
f"[{DIM}]({result.before_chars} chars -> {result.after_chars} chars)[/]"
)
session.record("slash", "/compact")
return True
@@ -0,0 +1,81 @@
"""Session listing slash command: /sessions."""
from __future__ import annotations
import contextlib
from rich.console import Console
from rich.markup import escape
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import (
BOLD_BRAND,
DIM,
print_repl_table,
repl_table,
)
from surfaces.interactive_shell.ui.components.time_format import (
format_repl_duration,
format_repl_timestamp,
)
def _cmd_sessions(session: Session, console: Console, _args: list[str]) -> bool:
from datetime import UTC, datetime
from core.agent_harness.session import default_session_repo
entries = default_session_repo().load_recent(20)
if not entries:
console.print(f"[{DIM}]No sessions recorded yet.[/]")
return True
table = repl_table(title="Recent sessions\n", title_style=BOLD_BRAND)
table.add_column("#", style="bold", justify="right")
table.add_column("Session ID", style="bold")
table.add_column("Name")
table.add_column("Started")
table.add_column("Duration")
table.add_column("Turns", justify="right")
table.add_column("Investigations", justify="right")
for i, entry in enumerate(entries, start=1):
sid = entry["session_id"]
short_id = sid[:8] if len(sid) >= 8 else sid
is_current = sid == session.session_id
name = entry.get("name") or ""
if is_current and not name and session.resumed_from_name:
name = f"{session.resumed_from_name}"
if is_current:
name_col = f"[{DIM}](current)[/]" if not name else f"{escape(name)} [{DIM}](current)[/]"
else:
name_col = escape(name) if name else f"[{DIM}]—[/]"
started_str = format_repl_timestamp(entry.get("started_at"), style="table")
duration_secs = entry.get("duration_secs")
if is_current:
with contextlib.suppress(OSError, OverflowError, ValueError):
elapsed = int(
(
datetime.now(UTC) - datetime.fromtimestamp(session.started_at, tz=UTC)
).total_seconds()
)
duration_secs = elapsed
total = entry.get("total_turns")
investigations = entry.get("investigation_turns")
table.add_row(
str(i),
short_id,
name_col,
started_str,
format_repl_duration(duration_secs),
str(total) if total is not None else "",
str(investigations) if investigations is not None else "",
)
print_repl_table(console, table)
return True
@@ -0,0 +1,232 @@
"""Session resume slash command and helpers: /resume."""
from __future__ import annotations
from rich.console import Console
from rich.markup import escape
from core.agent_harness.session import SessionManager
from surfaces.interactive_shell.command_registry.session_cmds.resume_rendering import (
render_resumed_session_history,
)
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING
from surfaces.interactive_shell.ui.components.choice_menu import (
repl_choose_one,
repl_tty_interactive,
)
from surfaces.interactive_shell.ui.components.time_format import format_repl_timestamp
def _record_resume_slash(
session: Session,
args: list[str],
*,
ok: bool = True,
picked_id: str | None = None,
) -> None:
"""Record /resume in the active session file after identity is settled."""
if picked_id:
text = f"/resume {picked_id[:8]}"
elif args:
text = f"/resume {' '.join(args)}"
else:
text = "/resume"
session.record("slash", text, ok=ok)
def _interactive_resume_menu(session: Session, console: Console) -> bool:
"""Show a numbered list of recent sessions and resume the selected one."""
from core.agent_harness.session import default_session_repo
entries = [
e for e in default_session_repo().load_recent(10) if e["session_id"] != session.session_id
]
if not entries:
console.print(f"[{DIM}]No previous sessions to resume.[/]")
return True
choices: list[tuple[str, str]] = []
for entry in entries:
sid = entry["session_id"]
short_id = sid[:8]
name = entry.get("name") or f"[{short_id}]"
started_str = format_repl_timestamp(entry.get("started_at"), style="compact")
label = f"{name[:40]:<40} {short_id} {started_str}"
choices.append((sid, label))
choices.append(("done", "done"))
picked = repl_choose_one(title="resume session", breadcrumb="/resume", choices=choices)
if picked is None or picked == "done":
return True
slash_command = f"/resume {picked[:8]}"
if not _do_resume(picked, session, console, slash_command=slash_command):
_record_resume_slash(session, [], picked_id=picked, ok=False)
return True
def _apply_resume_data(
data: dict,
session: Session,
console: Console,
*,
slash_command: str | None = None,
) -> bool:
"""Apply loaded session data into the running session and print a summary."""
messages = data.get("cli_agent_messages") or []
context = data.get("accumulated_context") or {}
history = data.get("history") or []
has_snapshot = data.get("has_snapshot", False)
sid = data.get("session_id", "")
short_id = sid[:8] if len(sid) >= 8 else sid
name = data.get("name") or ""
if not messages and not context:
console.print(
f"[{DIM}]session {short_id} has no conversation to resume "
"(no chat turns or context found).[/]"
)
if not data.get("turn_details") and not has_snapshot:
console.print(
f"[{DIM}]tip: turn_detail records are only written when prompt logging is enabled.[/]"
)
if slash_command:
session.record("slash", slash_command, ok=False)
return True
existing = session.agent.messages
if existing:
console.print(
f"[{WARNING}]current session has {len(existing)} messages — "
"they will be replaced by the resumed context.[/]"
)
manager = SessionManager.for_session(session)
manager.rebind_for_resume(
session,
session_id=sid,
started_at=data.get("started_at"),
)
manager.restore_context(session, data)
source = "snapshot" if has_snapshot else "turn records"
name_str = f" · {escape(name)}" if name else ""
console.print(
f"[{HIGHLIGHT}]resumed session {short_id}{name_str}[/] "
f"[{DIM}]({len(messages)} messages in context from {source})[/]"
)
render_resumed_session_history(
console,
history=history,
turn_details=data.get("turn_details") or [],
messages=list(messages),
)
if context:
console.print(
f"[{DIM}]accumulated context restored:[/] "
+ ", ".join(f"{escape(k)}={escape(str(v))}" for k, v in sorted(context.items()))
)
if slash_command:
session.record("slash", slash_command)
return True
def _lookup_resume_session_data(
prefix: str,
session: Session,
console: Console,
) -> dict | None:
"""Resolve a session to resume by ID prefix or name substring."""
from core.agent_harness.session import default_session_repo
repo = default_session_repo()
data = repo.load_session(prefix)
if data is None and len(prefix) >= 3:
candidates = [
e
for e in repo.load_recent(20)
if prefix.lower() in (e.get("name") or "").lower()
and e["session_id"] != session.session_id
]
if len(candidates) == 1:
data = repo.load_session(candidates[0]["session_id"])
elif len(candidates) > 1:
console.print(
f"[{WARNING}]'{escape(prefix)}' matches {len(candidates)} sessions by name — "
"use a session ID prefix or be more specific.[/]"
)
return None
if data is not None:
return data
n = repo.count_prefix_matches(prefix)
if n > 1:
console.print(
f"[{WARNING}]ambiguous prefix '{escape(prefix)}' matches {n} sessions — "
"use more characters.[/]"
)
else:
console.print(f"[{ERROR}]session '{escape(prefix)}' not found.[/]")
return None
def _do_resume(
prefix: str,
session: Session,
console: Console,
*,
slash_command: str | None = None,
) -> bool:
"""Load session by ID prefix and restore context into the running session."""
data = _lookup_resume_session_data(prefix, session, console)
if data is None:
return False
return _apply_resume_data(data, session, console, slash_command=slash_command)
def resume_session_by_prefix(
prefix: str,
session: Session,
console: Console,
*,
slash_command: str | None = None,
) -> bool:
"""Load session by ID prefix and restore context into the running session."""
return _do_resume(prefix, session, console, slash_command=slash_command)
def _cmd_resume(session: Session, console: Console, args: list[str]) -> bool:
if not args and repl_tty_interactive():
return _interactive_resume_menu(session, console)
if not args:
console.print(f"[{DIM}]usage: /resume <session-id-prefix>[/]")
console.print(f"[{DIM}]run /sessions to list session IDs.[/]")
_record_resume_slash(session, args)
return True
prefix = args[0].strip()
session_prefix = prefix.split(":", 1)[0]
if session.session_id.startswith(session_prefix) and ":" not in prefix:
console.print(
f"[{DIM}]session {session_prefix[:8]} is the current session — "
"run /sessions to pick a previous one.[/]"
)
_record_resume_slash(session, args)
return True
data = _lookup_resume_session_data(prefix, session, console)
if data is None:
_record_resume_slash(session, args, ok=False)
return True
slash_command = f"/resume {' '.join(args)}" if args else "/resume"
_apply_resume_data(data, session, console, slash_command=slash_command)
return True
@@ -0,0 +1,90 @@
"""Presentation for /resume: render a resumed session's prior activity.
Pure rendering — takes a console plus already-loaded session data and prints it
in REPL turn order. Holds no lookup or orchestration logic so the resume command
module stays focused on the resume flow.
"""
from __future__ import annotations
from collections import deque
from rich.console import Console
from rich.markup import escape
from surfaces.interactive_shell.ui import DIM, HIGHLIGHT
_HISTORY_DISPLAY_CHAT_KINDS: frozenset[str] = frozenset(
{"chat", "cli_agent", "follow_up", "alert", "incoming_alert"}
)
def _response_for_prompt(turn_details: list[dict], prompt: str) -> str:
for detail in turn_details:
if detail.get("prompt") == prompt:
return str(detail.get("response") or "")
return ""
def render_resumed_session_history(
console: Console,
*,
history: list[dict],
turn_details: list[dict],
messages: list[tuple[str, str]],
) -> None:
"""Render prior session activity in REPL turn order, including slash commands."""
from rich.markdown import Markdown
from platform.terminal.theme import MARKDOWN_THEME
from surfaces.interactive_shell.ui.streaming import render_response_header
if not history and not messages:
return
console.print(f"[{DIM}]─── conversation history ─────────────────────────────────[/]")
if history:
assistant_by_user: dict[str, deque[str]] = {}
pending_user: str | None = None
for role, text in messages:
if role == "user":
pending_user = text
elif role == "assistant" and pending_user is not None:
assistant_by_user.setdefault(pending_user, deque()).append(text)
pending_user = None
for rec in history:
kind = rec.get("kind", "")
text = rec.get("text") or ""
if kind == "slash":
console.print(f"[bold]$ {escape(text)}[/bold]")
continue
if kind not in _HISTORY_DISPLAY_CHAT_KINDS or not text:
continue
console.print(f"[bold {HIGHLIGHT}][/] {escape(text)}")
response = _response_for_prompt(turn_details, text)
if not response:
queued = assistant_by_user.get(text)
response = queued.popleft() if queued else ""
if response:
render_response_header(console, "assistant")
with console.use_theme(MARKDOWN_THEME):
console.print(Markdown(response, code_theme="ansi_dark"))
console.print(f"[{DIM}]─────────────────────────────────────────────────────────[/]")
return
has_pending_user = False
for role, text in messages:
if role == "user":
console.print(f"[bold {HIGHLIGHT}][/] {escape(text)}")
has_pending_user = True
elif role == "assistant" and has_pending_user:
render_response_header(console, "assistant")
with console.use_theme(MARKDOWN_THEME):
console.print(Markdown(text, code_theme="ansi_dark"))
has_pending_user = False
console.print(f"[{DIM}]─────────────────────────────────────────────────────────[/]")
__all__ = ["render_resumed_session_history"]
@@ -0,0 +1,178 @@
"""Slash commands: session settings (/trust, /effort, /verbose, /compact)."""
from __future__ import annotations
import os
from rich.console import Console
from rich.markup import escape
import surfaces.interactive_shell.command_registry.repl_data as repl_data
from config.llm_reasoning_effort import (
REASONING_EFFORT_OPTIONS,
describe_reasoning_effort_default,
display_reasoning_effort,
parse_reasoning_effort,
provider_supports_reasoning_effort,
)
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import (
DIM,
ERROR,
HIGHLIGHT,
WARNING,
resolve_provider_models,
)
from surfaces.interactive_shell.ui.components.choice_menu import (
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
_TRUST_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("on", "enable trust mode (skip approval prompts)"),
("off", "disable trust mode"),
)
_VERBOSE_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("on", "enable verbose logging"),
("off", "disable verbose logging"),
)
_EFFORT_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("low", "favor speed and lower reasoning cost"),
("medium", "balanced reasoning effort"),
("high", "favor more thorough reasoning"),
("xhigh", "favor deepest supported reasoning"),
("max", "alias for xhigh"),
)
def _interactive_trust_menu(session: Session, console: Console) -> bool:
while True:
mode = repl_choose_one(
title="trust",
breadcrumb="/trust",
choices=[("on", "on"), ("off", "off"), ("done", "done")],
)
if mode is None or mode == "done":
return True
_cmd_trust(session, console, [mode])
repl_section_break(console)
def _cmd_trust(session: Session, console: Console, args: list[str]) -> bool:
if not args and repl_tty_interactive():
return _interactive_trust_menu(session, console)
if args and args[0].lower() in ("off", "false", "disable"):
session.terminal.trust_mode = False
console.print(f"[{DIM}]trust mode off[/]")
else:
session.terminal.trust_mode = True
console.print(f"[{WARNING}]trust mode on[/] — future approval prompts will be skipped")
return True
def _cmd_effort(session: Session, console: Console, args: list[str]) -> bool:
settings = repl_data.load_llm_settings()
provider = str(getattr(settings, "provider", os.getenv("LLM_PROVIDER", "anthropic")))
reasoning_model = ""
if settings is not None:
reasoning_model, _toolcall_model = resolve_provider_models(settings, provider)
supported_values = ", ".join(REASONING_EFFORT_OPTIONS)
if not args:
console.print(
f"[{HIGHLIGHT}]reasoning effort:[/] {display_reasoning_effort(session.reasoning_effort)}"
)
console.print(
f"[{DIM}]default config:[/] "
f"{escape(describe_reasoning_effort_default(provider, reasoning_model))}"
)
console.print(f"[{DIM}]usage:[/] /effort <{supported_values}>")
if not provider_supports_reasoning_effort(provider):
console.print(
f"[{DIM}]current provider {provider} ignores this setting; "
"switch to openai or codex to use it.[/]"
)
return True
effort = parse_reasoning_effort(args[0])
if effort is None:
console.print(
f"[{ERROR}]unknown reasoning effort:[/] {escape(args[0])} "
f"[{DIM}](choices: {supported_values})[/]"
)
session.mark_latest(ok=False, kind="slash")
return True
session.reasoning_effort = effort
console.print(f"[{HIGHLIGHT}]reasoning effort set to:[/] {display_reasoning_effort(effort)}")
if not provider_supports_reasoning_effort(provider):
console.print(
f"[{DIM}]current provider {provider} ignores this setting; "
"switch to openai or codex to use it.[/]"
)
elif effort in {"xhigh", "max"}:
console.print(
f"[{DIM}]xhigh/max work best with newer GPT-5 or Codex models; "
"older reasoning models may reject them.[/]"
)
return True
def _interactive_verbose_menu(_session: Session, console: Console) -> bool:
while True:
mode = repl_choose_one(
title="verbose",
breadcrumb="/verbose",
choices=[("on", "on"), ("off", "off"), ("done", "done")],
)
if mode is None or mode == "done":
return True
_cmd_verbose(_session, console, [mode])
repl_section_break(console)
def _cmd_verbose(_session: Session, console: Console, args: list[str]) -> bool:
if not args and repl_tty_interactive():
return _interactive_verbose_menu(_session, console)
if args and args[0].lower() in ("off", "false", "0", "disable"):
os.environ.pop("TRACER_VERBOSE", None)
console.print(f"[{DIM}]verbose logging off[/]")
else:
os.environ["TRACER_VERBOSE"] = "1"
console.print(f"[{WARNING}]verbose logging on[/]")
return True
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/trust",
"Manage trust mode.",
_cmd_trust,
usage=("/trust", "/trust on", "/trust off"),
notes=("In a TTY, bare /trust opens an interactive menu.",),
first_arg_completions=_TRUST_FIRST_ARGS,
),
SlashCommand(
"/effort",
"Set REPL reasoning effort.",
_cmd_effort,
usage=("/effort <low|medium|high|xhigh|max>",),
first_arg_completions=_EFFORT_FIRST_ARGS,
),
SlashCommand(
"/verbose",
"Manage verbose logging.",
_cmd_verbose,
usage=("/verbose", "/verbose on", "/verbose off"),
notes=("In a TTY, bare /verbose opens an interactive menu.",),
first_arg_completions=_VERBOSE_FIRST_ARGS,
),
]
__all__ = ["COMMANDS", "_TRUST_FIRST_ARGS", "_VERBOSE_FIRST_ARGS", "_EFFORT_FIRST_ARGS"]
@@ -0,0 +1,510 @@
"""MCP-style slash-command catalog for LLM planners and tool specs."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from core.agent_harness.tools.tool_context import (
object_schema,
string_array_property,
string_property,
)
from surfaces.interactive_shell.command_registry.types import SlashCommand
_MAX_COMPACT_DESC_CHARS = 120
@dataclass(frozen=True)
class SlashCommandSpec:
name: str
description: str
llm_description: str
use_cases: tuple[str, ...]
anti_examples: tuple[str, ...]
usage: tuple[str, ...]
examples: tuple[str, ...]
args_schema: dict[str, Any] | None
@dataclass(frozen=True)
class _SlashMcpFields:
llm_description: str
use_cases: tuple[str, ...]
anti_examples: tuple[str, ...] = ()
def _mcp(
llm_description: str,
*use_cases: str,
anti_examples: tuple[str, ...] = (),
) -> _SlashMcpFields:
return _SlashMcpFields(
llm_description=llm_description,
use_cases=use_cases,
anti_examples=anti_examples,
)
_MCP_BY_COMMAND: dict[str, _SlashMcpFields] = {
"/?": _mcp(
"Shortcut for /help — open the interactive slash-command help browser.",
"User types ? or asks for command help via the shortcut alias",
anti_examples=("User asks a docs/how-to question about OpenSRE features",),
),
"/alerts": _mcp(
"Show status of the local alert listener inbox: queue depth, dropped count, "
"and the most recent ingested alerts.",
"User asks about the alert inbox, listener, or queued alerts",
anti_examples=("User wants to investigate an alert body (use investigation_start)",),
),
"/auth": _mcp(
"Log in to LLM providers and inspect local auth state. Subcommands: login, status, logout.",
"User asks to log in, authenticate, connect an LLM provider, or check provider auth",
anti_examples=("User asks to configure an observability integration (use /integrations)",),
),
"/background": _mcp(
"Manage session-local background investigation mode and completed RCA summaries. "
"Subcommands: on, off, status, list, show <task_id>, use <task_id>, notify list, notify set.",
"User asks to enable or disable background investigation mode",
"User asks to list or inspect completed background RCAs",
"User asks to configure background RCA notification channels",
),
"/cancel": _mcp(
"Cancel a running background task by task id. Requires confirmation in non-trust mode.",
"User asks to cancel a specific task when they provide or imply a task id",
anti_examples=("User asks to stop everything without an id (use /stop guidance first)",),
),
"/clear": _mcp(
"Clear the terminal screen and re-render the OpenSRE banner.",
"User asks to clear the screen or terminal",
),
"/compact": _mcp(
"Compact the current long-running session context into a replayable summary entry "
"while keeping recent messages available for the next turn.",
"User asks to compact or summarize the current session context",
"User wants to reduce context size without starting a new session",
),
"/config": _mcp(
"Show or edit local OpenSRE configuration (~/.opensre/config.yml). "
"Subcommands: show, set <key> <value>.",
"User asks to view or change OpenSRE config settings",
anti_examples=("User asks how to configure an integration (may need assistant_handoff)",),
),
"/context": _mcp(
"Display accumulated infrastructure context collected during the session.",
"User asks what context or infra metadata the session has accumulated",
),
"/cost": _mcp(
"Show token usage and estimated session cost for LLM calls in this REPL session.",
"User asks about token usage, cost, or spend in the current session",
),
"/cron": _mcp(
"Manage cron-driven scheduled deliveries. "
"Subcommands: list, add, remove <id>, run <id>, logs <id>.",
"User asks to list, add, remove, run, or view logs for scheduled delivery tasks",
anti_examples=("User asks about one-off messaging without a schedule (use /messaging)",),
),
"/doctor": _mcp(
"Run a full local environment diagnostic and print pass/warn/fail per check.",
"User asks for environment diagnostics, setup validation, or doctor check",
anti_examples=("User only wants integration health (use /health instead)",),
),
"/effort": _mcp(
"Set REPL reasoning effort level: low, medium, high, xhigh, or max.",
"User asks to change reasoning effort or depth for the active model",
anti_examples=("User asks to switch provider or model name (use /model)",),
),
"/exit": _mcp(
"Exit the interactive shell and return to the parent terminal.",
"User asks to exit, quit, or leave the REPL",
),
"/fleet": _mcp(
"Show and manage the local AI agent fleet (Claude Code, Cursor, Aider, etc.). "
"Subcommands include budget, bus, claim, conflicts, kill, release, trace, wait, graph.",
"User asks to list, scan, or manage local coding agents",
anti_examples=("User asks about remote/hosted agents only",),
),
"/gateway": _mcp(
"Control the background OpenSRE gateway daemon (web health app, Telegram chat, "
"task scheduler). Subcommands: start, stop, status, logs [lines].",
"User asks to start, stop, check, or read logs of the gateway daemon",
anti_examples=("User asks to send a single Telegram message (use messaging tools)",),
),
"/guardrails": _mcp(
"Manage sensitive-information guardrail rules. Subcommands: audit, init, rules, test.",
"User asks about guardrails, PII rules, or sensitive-data masking configuration",
),
"/health": _mcp(
"Run a read-only health check of the local OpenSRE agent, LLM connectivity, "
"and configured integrations with pass/fail per component.",
"User asks if OpenSRE is healthy, working, or connected",
anti_examples=(
"User asks what integrations OpenSRE supports in general (docs → assistant_handoff)",
"User asks to list connected integrations (use /integrations list)",
),
),
"/help": _mcp(
"Show the slash-command help index or detailed help for a command or category.",
"User asks for available commands or help using /help",
anti_examples=("User asks a procedural docs question (assistant_handoff)",),
),
"/hermes": _mcp(
"Live-tail Hermes logs and send detected incidents to Telegram. Subcommand: watch.",
"User asks to watch Hermes logs or Hermes incident escalation",
),
"/history": _mcp(
"Manage persisted command history: clear, off, on, retention <N>.",
"User asks to clear, disable, or configure command history persistence",
),
"/integrations": _mcp(
"Manage configured integrations. Subcommands: list, verify, show <service>, remove.",
"User asks to verify an integration by name",
"User asks to show details for a configured integration",
anti_examples=(
"User asks which integrations OpenSRE supports without configuring (assistant_handoff)",
"User asks to list connected integrations (prefer /integrations list)",
),
),
"/investigate": _mcp(
"Run an RCA investigation from a local alert file path or a built-in sample template.",
"User asks to investigate a file path or run RCA from a saved alert file",
"User asks to run one of the built-in sample alerts/templates",
anti_examples=(
"User pastes alert text inline (use investigation_start instead)",
"User asks how investigations work (assistant_handoff)",
),
),
"/last": _mcp(
"Reprint the most recent investigation report from this session.",
"User asks to show the last investigation result or report again",
),
"/login": _mcp(
"Shortcut for /auth login. Supports subscription aliases chatgpt and claude, "
"and API-key providers such as deepseek.",
"User asks to log in to ChatGPT, Claude, DeepSeek, or another LLM provider",
anti_examples=("User asks to log in to a non-LLM integration (use /integrations or /mcp)",),
),
"/rca": _mcp(
"Browse persisted RCA reports across sessions. Subcommands: history, show <id>, save <path>.",
"User asks for past RCA reports, investigation history, or to export a previous root-cause report",
anti_examples=("User asks for command history or up-arrow recall (use /history)",),
),
"/mcp": _mcp(
"Manage connected MCP servers. Subcommands: list, connect, disconnect.",
"User asks to list, connect, or disconnect MCP servers",
anti_examples=("User asks about remote deployments or remote agents (use /remote)",),
),
"/messaging": _mcp(
"Manage messaging security and Telegram identities. Subcommands: pair, allow, revoke, status.",
"User asks about Telegram pairing, messaging allowlist, or messaging status",
),
"/misses": _mcp(
"Triage investigation misses and export them as benchmark regression scenarios. "
"Subcommands: list, stats, export --out <dir>, convert <miss_id>.",
"User asks about investigation misses, miss triage, or miss trends",
"User asks to convert recent misses into regression scenarios or evals",
anti_examples=(
"User asks for raw feedback ratings without taxonomy (read ~/.opensre/feedback.jsonl directly)",
),
),
"/model": _mcp(
"Explicit /model command operations: show the model table, open the model menu, "
"set/restore the active LLM provider or reasoning model, or manage the toolcall model.",
"User explicitly types /model or asks to run /model show",
"User asks to change, set, restore, or switch the active provider or model",
anti_examples=(
"User asks a natural-language model status question like 'which model is being used now?' (assistant_handoff)",
"User asks what model/provider the assistant is using (assistant_handoff)",
"User asks whether OpenAI is configured now without explicitly asking to run /model or verify credentials (assistant_handoff)",
"User says switch to local llama without a concrete provider (assistant_handoff)",
),
),
"/onboard": _mcp(
"Launch the interactive onboarding wizard (handoff if run inside the REPL).",
"User asks to run onboarding or initial setup wizard",
),
"/privacy": _mcp(
"Show history persistence settings, redaction status, and the local threat model.",
"User asks about privacy, history encryption, or data retention in the shell",
),
"/quit": _mcp(
"Alias for /exit — leave the interactive shell.",
"User asks to quit the REPL",
),
"/remote": _mcp(
"Connect to, list, and operate remote deployed OpenSRE agents. "
"Subcommands: health, investigate, ops, pull, trigger.",
"User explicitly asks to connect to a remote/hosted/EC2/Nitro OpenSRE instance",
"User asks how many remote deployments are configured or wants to inspect a remote agent",
"User asks about remote deployment status, health, or operations",
anti_examples=("Vague connect to X without remote/hosted context (assistant_handoff)",),
),
"/new": _mcp(
"Start a new session while preserving the current LLM conversation context and "
"accumulated infra context. Rotates the session ID and resets all session state "
"while keeping the conversation thread so you can continue seamlessly in a fresh session file.",
"User wants to continue a conversation in a new session after /resume",
"User asks to start a new session without losing their current conversation",
anti_examples=(
"User wants to clear the screen (use /clear)",
"User asks to list sessions (use /sessions)",
),
),
"/resume": _mcp(
"Restore the conversation context from a previous session. "
"Bare /resume opens an interactive numbered picker. "
"Pass a session ID prefix, a session:entry reference, or a name substring to resume directly "
"(e.g. /resume 9b2e4f7a, /resume 9b2e4f7a:abc123, or /resume redis).",
"User asks to resume or continue a previous session",
"User wants to pick up where they left off in an earlier REPL session",
"User types /resume with no argument to pick from a list",
anti_examples=(
"User asks to list sessions (use /sessions)",
"User asks to start a new session keeping context (use /new)",
),
),
"/save": _mcp(
"Save the last investigation report to a file path. Requires confirmation.",
"User asks to export or save the last investigation to disk",
),
"/sessions": _mcp(
"List recent REPL sessions stored on disk. Shows session ID, start time, duration, "
"total turns, and investigation count for each session.",
"User asks to see past sessions, session history, or what was run in previous sessions",
anti_examples=("User asks for the current session status (use /status)",),
),
"/status": _mcp(
"Explicit /status command operation: show REPL session status, including "
"provider, models, trust mode, and active flags.",
"User explicitly types /status or asks to run /status",
anti_examples=(
"User asks conversationally what the current session status is (assistant_handoff)",
"User asks if integrations are healthy (use /health)",
),
),
"/stop": _mcp(
"Print guidance for stopping in-flight investigations and background tasks.",
"User asks how to stop a running investigation or background work",
anti_examples=("User provides a task id to cancel (use /cancel)",),
),
"/tasks": _mcp(
"List recent and in-flight shell background tasks with ids and status.",
"User asks to list running or recent tasks",
),
"/template": _mcp(
"Print a starter alert JSON template (generic, datadog, grafana, honeycomb, coralogix, splunk).",
"User asks for an alert template or example payload format",
),
"/tools": _mcp(
"Explicit /tools command operation: list registered investigation/chat tools "
"wired into this OpenSRE build.",
"User explicitly types /tools or asks to run /tools",
"User explicitly asks to list registered tools as a shell command",
anti_examples=(
"User asks conversationally what tools or capabilities the assistant can use (assistant_handoff)",
),
),
"/tests": _mcp(
"Browse and run inventoried tests from the terminal. Subcommands: list, run, synthetic.",
"User asks to list or run bundled tests via /tests",
),
"/theme": _mcp(
"Choose and persist the interactive shell color palette (TTY picker or /theme <name>).",
"User asks to change the REPL color theme or palette",
anti_examples=("User asks about light/dark mode in a web UI",),
),
"/trust": _mcp(
"Enable or disable trust mode (skip execution confirmation prompts). on | off.",
"User asks to enable or disable trust mode or auto-approve",
),
"/uninstall": _mcp(
"Remove OpenSRE and all local data from this machine. Destructive — requires confirmation.",
"User explicitly asks to uninstall OpenSRE locally",
),
"/unwatch": _mcp(
"Cancel a running watchdog task by task id. Requires confirmation.",
"User asks to stop a /watch background task by id",
),
"/update": _mcp(
"Check for a newer OpenSRE version and update if available.",
"User asks to update or upgrade OpenSRE",
),
"/verbose": _mcp(
"Toggle verbose logging in the REPL. on | off.",
"User asks to enable or disable verbose logging",
),
"/verify": _mcp(
"Run connectivity checks on configured integrations. "
"Bare /verify checks all; pass a service name to verify one.",
"User asks to verify integrations or check if a named integration is reachable",
"User asks to verify one integration by name (e.g. datadog, telegram)",
anti_examples=(
"User asks for full agent and LLM health (use /health)",
"User asks to show integration config details (use /integrations show)",
),
),
"/version": _mcp(
"Print OpenSRE version, Python version, and OS information.",
"User asks for version information",
),
"/watch": _mcp(
"Watch a process by PID and send Telegram threshold alarms. Requires confirmation.",
"User asks to watch a process or set resource threshold alarms",
),
"/watchdog": _mcp(
"Monitor one process and send threshold alarms (CLI parity wrapper).",
"User asks to run the watchdog monitor CLI from the REPL",
),
"/watches": _mcp(
"List active watchdog background tasks with latest resource samples.",
"User asks to list running watchdog watches",
),
"/debug": _mcp(
"Run targeted runtime diagnostics (e.g. /debug sentry to trigger a Sentry smoke test).",
"User asks to run a debug check or diagnostic",
anti_examples=("User asks a general debugging or troubleshooting question",),
),
}
def _resolve_mcp_fields(command: SlashCommand) -> _SlashMcpFields:
registry = _MCP_BY_COMMAND.get(command.name)
llm_description = (
command.llm_description or (registry.llm_description if registry else "")
).strip()
if not llm_description:
llm_description = command.description.strip()
if command.usage:
llm_description = f"{llm_description} Common forms: {', '.join(command.usage[:3])}."
use_cases = command.use_cases or (registry.use_cases if registry else ())
if not use_cases:
use_cases = (f"User intent matches: {command.description.rstrip('.')}",)
anti_examples = command.anti_examples or (registry.anti_examples if registry else ())
return _SlashMcpFields(
llm_description=llm_description,
use_cases=use_cases,
anti_examples=anti_examples,
)
def _derive_args_schema(command: SlashCommand) -> dict[str, Any] | None:
if command.args_schema is not None:
return command.args_schema
if not command.first_arg_completions:
return None
hints = "; ".join(f"{keyword} ({label})" for keyword, label in command.first_arg_completions)
return {
"type": "array",
"items": {"type": "string"},
"description": (
f"Positional arguments after {command.name}. First-argument options: {hints}."
),
}
def spec_from_command(command: SlashCommand) -> SlashCommandSpec:
mcp = _resolve_mcp_fields(command)
return SlashCommandSpec(
name=command.name,
description=command.description,
llm_description=mcp.llm_description,
use_cases=mcp.use_cases,
anti_examples=mcp.anti_examples,
usage=command.usage,
examples=command.examples,
args_schema=_derive_args_schema(command),
)
def build_slash_command_specs(
commands: dict[str, SlashCommand] | None = None,
) -> list[SlashCommandSpec]:
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS
source = commands if commands is not None else SLASH_COMMANDS
return [spec_from_command(source[name]) for name in sorted(source.keys())]
def format_slash_catalog_text(
specs: list[SlashCommandSpec] | None = None,
*,
compact: bool = False,
) -> str:
entries = specs if specs is not None else build_slash_command_specs()
if not entries:
return ""
lines: list[str] = []
for spec in entries:
desc = spec.llm_description
if compact and len(desc) > _MAX_COMPACT_DESC_CHARS:
desc = desc[: _MAX_COMPACT_DESC_CHARS - 1].rstrip() + ""
lines.append(f"- **{spec.name}** — {desc}")
if spec.use_cases and not compact:
lines.append(f" - use when: {spec.use_cases[0]}")
if spec.anti_examples and not compact:
lines.append(f" - not for: {spec.anti_examples[0]}")
if spec.usage and not compact:
lines.append(f" - usage: {', '.join(spec.usage[:2])}")
return "\n".join(lines)
def slash_invoke_tool_description(specs: list[SlashCommandSpec] | None = None) -> str:
entries = specs if specs is not None else build_slash_command_specs()
header = (
"Run a slash command in the OpenSRE interactive shell. "
"Use this only for explicit slash-command operations: literal /command "
"text, requests that explicitly ask to run a slash command, or "
"operation/discovery cases that the system prompt explicitly maps to a "
"slash command. Do not use this as a natural-language router for "
"ordinary informational, how-to, capability, or status questions merely "
"because a slash command can display related information; hand those to "
"assistant_handoff unless a prompt rule names a read-only discovery "
"exception. Supply positional args in the args array. This tool covers "
"only the slash-command clause of a request. For compound requests, "
"still emit a separate tool call for every other actionable clause in "
"order; for example "
'`run /remote and then investigate "hello world"` requires '
'slash_invoke(command="/remote", args=[]) followed by '
'investigation_start(alert_text="hello world").'
)
# Keep planner payload intentionally tiny for live LLM runs with strict
# prompt budgets. The full rich catalog remains available via
# format_slash_catalog_text(..., compact=False).
body = "\n".join(f"- `{spec.name}`" for spec in entries)
return f"{header}\n\n{body}"
def slash_invoke_input_schema(
specs: list[SlashCommandSpec] | None = None,
) -> dict[str, Any]:
entries = specs if specs is not None else build_slash_command_specs()
command_names = tuple(spec.name for spec in entries)
args_description = (
"Positional arguments after the command name. Valid values depend on the "
"chosen command — see the slash_invoke tool description. Examples: "
'["list"] for /tools, ["verify", "datadog"] for /integrations.'
)
return object_schema(
properties={
"command": string_property(
description="Slash command name including leading `/`.",
enum=command_names,
),
"args": string_array_property(description=args_description),
},
required=("command",),
)
__all__ = [
"SlashCommandSpec",
"build_slash_command_specs",
"format_slash_catalog_text",
"slash_invoke_input_schema",
"slash_invoke_tool_description",
"spec_from_command",
]
@@ -0,0 +1,128 @@
"""Small helpers for human-friendly slash-command suggestions."""
from __future__ import annotations
import re
from dataclasses import dataclass
from difflib import get_close_matches
from typing import Literal
from surfaces.interactive_shell.command_registry.types import SlashCommand
SlashOutcome = Literal["unknown_command", "invalid_subcommand"]
_RICH_TAG_RE = re.compile(r"\[[^\]]+\]")
@dataclass(frozen=True, slots=True)
class LiteralSlashTypo:
"""A user-typed literal slash line that should not run."""
message: str
outcome: SlashOutcome
def closest_choice(value: str, choices: list[str] | tuple[str, ...]) -> str | None:
"""Return the nearest command-like choice for a typo, if confidence is high enough."""
normalized = value.strip().lower()
if not normalized:
return None
matches = get_close_matches(normalized, choices, n=1, cutoff=0.72)
return matches[0] if matches else None
def subcommand_hints(cmd: SlashCommand) -> tuple[str, ...]:
"""Return enumerable first-argument keywords for ``cmd``.
Only ``first_arg_completions`` are used — usage strings often contain
free-form placeholders like ``<session-id-prefix>`` that must not be
treated as literal subcommands.
"""
return tuple(sorted({keyword.lower() for keyword, _label in cmd.first_arg_completions}))
def format_unknown_slash_message(
command_line: str,
*,
command_names: tuple[str, ...],
) -> str:
"""Plain-text guidance for an unknown slash command root."""
stripped = command_line.strip()
name = stripped.split()[0] if stripped else stripped
suggestion = closest_choice(name, command_names)
if suggestion:
return (
f"Unknown command: {name}. "
f"Did you mean {suggestion}? "
"Type /help for the full command list."
)
return f"Unknown command: {name}. Type /help for the full command list."
def format_invalid_subcommand_message(
cmd: SlashCommand,
args: list[str],
) -> str:
"""Plain-text guidance for an invalid subcommand on a known slash command."""
subcommand = args[0] if args else ""
hints = subcommand_hints(cmd)
if hints:
choices_text = ", ".join(f"{cmd.name} {hint}" for hint in hints)
return (
f"Invalid subcommand: {subcommand}. "
f"Try one of: {choices_text}. "
f"Type /help for the full command list."
)
return f"Invalid subcommand: {subcommand}. Type /help for the full command list."
def resolve_literal_slash_typo(
command_line: str,
registry: dict[str, SlashCommand],
) -> LiteralSlashTypo | None:
"""Return typo guidance when a user-typed literal slash line should not run."""
stripped = command_line.strip()
if not stripped.startswith("/"):
return None
parts = stripped.split()
if not parts:
return None
name = parts[0].lower()
args = parts[1:]
command_names = tuple(registry)
cmd = registry.get(name)
if cmd is None:
return LiteralSlashTypo(
message=format_unknown_slash_message(stripped, command_names=command_names),
outcome="unknown_command",
)
if cmd.validate_args is not None:
validation_error = cmd.validate_args(args)
if validation_error is not None and args:
return LiteralSlashTypo(
message=format_invalid_subcommand_message(cmd, args),
outcome="invalid_subcommand",
)
return None
def strip_rich_markup(text: str) -> str:
"""Best-effort plain text for analytics payloads sourced from Rich strings."""
return _RICH_TAG_RE.sub("", text).strip()
__all__ = [
"LiteralSlashTypo",
"SlashOutcome",
"closest_choice",
"format_invalid_subcommand_message",
"format_unknown_slash_message",
"resolve_literal_slash_typo",
"strip_rich_markup",
"subcommand_hints",
]
@@ -0,0 +1,114 @@
"""Slash commands: diagnostics, version, exit."""
from __future__ import annotations
from rich.console import Console
import platform
from platform.terminal.prompt_support import print_session_resume_hint
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import (
BOLD_BRAND,
DIM,
ERROR,
HIGHLIGHT,
WARNING,
print_repl_table,
repl_table,
)
def _flush_analytics_on_exit(console: Console) -> None:
"""Best-effort PostHog drain with a spinner so /quit is not silent or fire-and-forget."""
from platform.analytics.provider import analytics_needs_flush, shutdown_analytics
if not analytics_needs_flush():
shutdown_analytics(flush=False)
return
if console.is_terminal:
with console.status(
f"[{DIM}]finishing up…[/]",
spinner="dots",
spinner_style=DIM,
):
shutdown_analytics(flush=True)
else:
shutdown_analytics(flush=True)
def _cmd_exit(session: Session, console: Console, _args: list[str]) -> bool:
if session.session_id:
console.print()
print_session_resume_hint(console, session.session_id)
_flush_analytics_on_exit(console)
console.print(f"[{DIM}]goodbye.[/]")
return False
def _cmd_health(_session: Session, console: Console, _args: list[str]) -> bool:
from config.config import get_environment
from integrations.store import STORE_PATH
from integrations.verify import verify_integrations
from surfaces.interactive_shell.ui.health import render_health_report
results = verify_integrations()
environment = get_environment().value
render_health_report(
console=console,
environment=environment,
integration_store_path=STORE_PATH,
results=results,
)
return True
def _cmd_doctor(_session: Session, console: Console, _args: list[str]) -> bool:
from surfaces.cli.commands.doctor import _CHECKS, _check
status_styles: dict[str, str] = {"ok": HIGHLIGHT, "warn": WARNING, "error": ERROR}
table = repl_table(title="OpenSRE Doctor\n", title_style=BOLD_BRAND)
table.add_column("check", style="bold")
table.add_column("status")
table.add_column("detail", style=DIM, overflow="fold")
issues = 0
for name, fn in _CHECKS:
result = _check(name, fn)
status = result["status"]
style = status_styles.get(status, DIM)
table.add_row(name, f"[{style}]{status}[/]", result["detail"])
if status in ("warn", "error"):
issues += 1
print_repl_table(console, table)
if issues:
console.print(f"[{WARNING}]{issues} issue(s) found.[/]")
else:
console.print(f"[{HIGHLIGHT}]all checks passed.[/]")
return True
def _cmd_version(_session: Session, console: Console, _args: list[str]) -> bool:
from config.version import get_opensre_version
table = repl_table(title="Version info\n", title_style=BOLD_BRAND, show_header=False)
table.add_column("key", style="bold")
table.add_column("value")
table.add_row("opensre", get_opensre_version())
table.add_row("python", platform.python_version())
table.add_row("os", f"{platform.system().lower()} ({platform.machine()})")
print_repl_table(console, table)
return True
COMMANDS: list[SlashCommand] = [
SlashCommand("/exit", "Exit the interactive shell.", _cmd_exit),
SlashCommand("/quit", "Alias for /exit.", _cmd_exit),
SlashCommand("/health", "Show integration and agent health.", _cmd_health),
SlashCommand("/doctor", "Run full environment diagnostic.", _cmd_doctor),
SlashCommand("/version", "Print version, Python, and OS info.", _cmd_version),
]
__all__ = ["COMMANDS"]
@@ -0,0 +1,243 @@
"""Slash commands: /history, /tasks, /cancel."""
from __future__ import annotations
import re
from rich.console import Console
from rich.markup import escape
from surfaces.interactive_shell.command_registry.types import (
SlashCommand,
)
from surfaces.interactive_shell.prompt_history import load_command_history_entries
from surfaces.interactive_shell.runtime import Session, TaskKind, TaskRecord, TaskStatus
from surfaces.interactive_shell.ui import (
BOLD_BRAND,
DIM,
ERROR,
HIGHLIGHT,
WARNING,
print_repl_table,
repl_table,
)
from surfaces.interactive_shell.ui.components.time_format import format_repl_timestamp
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[mA-Za-z]")
_MAX_DETAIL_CHARS = 120
_WATCHDOG_PID = re.compile(r"pid=(\d+)")
def _task_started_label(task: TaskRecord) -> str:
return format_repl_timestamp(task.started_at, style="utc")
def _task_duration_label(task: TaskRecord) -> str:
duration = task.duration_seconds()
if duration is None:
return ""
return f"{duration:.1f}s"
def _synthetic_scenario_label(command: str) -> str:
"""Extract the short scenario identifier from a synthetic test command string."""
if "--scenario" in command:
return command.split("--scenario", 1)[1].strip()
if command.strip().endswith("all"):
return "all"
return command.strip()
def _clean_first_line(text: str) -> str:
"""Strip ANSI codes and return the first non-empty line of ``text``."""
clean = _ANSI_ESCAPE.sub("", text)
return next((line.strip() for line in clean.splitlines() if line.strip()), clean.strip())
def _kind_label(task: TaskRecord) -> str:
"""Return a concise kind label — for synthetic tests use the scenario name."""
if task.kind == TaskKind.SYNTHETIC_TEST and task.command:
return _synthetic_scenario_label(task.command)
if task.kind == TaskKind.WATCHDOG and task.command:
match = _WATCHDOG_PID.search(task.command)
if match:
return f"watchdog {match.group(1)}"
return task.kind.value
def _task_detail_label(task: TaskRecord) -> str:
if task.status == TaskStatus.RUNNING and task.progress:
line = _clean_first_line(task.progress)
if len(line) > _MAX_DETAIL_CHARS:
return line[:_MAX_DETAIL_CHARS] + ""
return line or ""
# Synthetic tests: the kind column already carries the scenario, so show
# only the compact outcome here (e.g. "exit code 1" or "ok").
if task.kind == TaskKind.SYNTHETIC_TEST:
if task.error:
err_line = _clean_first_line(task.error)
# "exit code 1: …" → keep only "exit code 1"
outcome = err_line.split(":")[0].strip() if ":" in err_line else err_line
return outcome or ""
if task.result:
return task.result
if task.command:
return _synthetic_scenario_label(task.command)
return ""
if task.kind == TaskKind.WATCHDOG:
if task.error:
raw = task.error
elif task.result:
raw = task.result
elif task.command:
raw = task.command
else:
return ""
first_line = _clean_first_line(raw)
if len(first_line) > _MAX_DETAIL_CHARS:
return first_line[:_MAX_DETAIL_CHARS] + ""
return first_line or ""
# All other task kinds: show error > result > command, first line, truncated.
if task.error:
raw = task.error
elif task.result:
raw = task.result
elif task.command:
raw = task.command
else:
return ""
first_line = _clean_first_line(raw)
if len(first_line) > _MAX_DETAIL_CHARS:
return first_line[:_MAX_DETAIL_CHARS] + ""
return first_line or ""
def _cmd_history(_session: Session, console: Console, _args: list[str]) -> bool:
entries = load_command_history_entries()
if not entries:
console.print(f"[{DIM}]no history yet.[/]")
return True
table = repl_table(title="Command history\n", title_style=BOLD_BRAND)
table.add_column("#", style=DIM, justify="right")
table.add_column("text", overflow="fold")
for i, entry in enumerate(entries, start=1):
table.add_row(str(i), escape(entry))
print_repl_table(console, table)
return True
def _cmd_tasks(session: Session, console: Console, _args: list[str]) -> bool:
tasks = session.task_registry.list_recent(n=50)
if not tasks:
console.print(f"[{DIM}]no tasks recorded this session.[/]")
return True
table = repl_table(title="Tasks\n", title_style=BOLD_BRAND)
table.add_column("id", style="bold")
table.add_column("kind")
table.add_column("status")
table.add_column("started", style=DIM)
table.add_column("duration", style=DIM, justify="right")
table.add_column("detail", style=DIM, overflow="fold")
status_style = {
TaskStatus.RUNNING: WARNING,
TaskStatus.COMPLETED: HIGHLIGHT,
TaskStatus.CANCELLED: WARNING,
TaskStatus.FAILED: ERROR,
TaskStatus.PENDING: DIM,
}
for task in tasks:
st = status_style.get(task.status, DIM)
table.add_row(
task.task_id,
_kind_label(task),
f"[{st}]{task.status.value}[/]",
_task_started_label(task),
_task_duration_label(task),
escape(_task_detail_label(task)),
)
print_repl_table(console, table)
return True
def _cmd_stop(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
console.print(
f"[{DIM}]in-flight work: press[/] [bold]Ctrl+C[/bold] "
f"[{DIM}]during a streaming investigation, or run[/] [{HIGHLIGHT}]/tasks[/] "
f"[{DIM}]then[/] [{HIGHLIGHT}]/cancel <id>[/] [{DIM}]for background tasks.[/]"
)
return True
def _validate_cancel_args(args: list[str]) -> str | None:
if not args:
return f"[{ERROR}]usage:[/] /cancel <task_id> — use [{HIGHLIGHT}]/tasks[/] to list ids"
return None
def _cmd_cancel(session: Session, console: Console, args: list[str]) -> bool:
needle = args[0]
candidates = session.task_registry.candidates(needle)
if not candidates:
console.print(f"[{ERROR}]no task matches id:[/] {escape(needle)}")
return True
if len(candidates) > 1:
console.print(
f"[{ERROR}]ambiguous id prefix:[/] {escape(needle)} "
f"[{DIM}]({len(candidates)} matches — use a longer prefix)[/]"
)
return True
task = candidates[0]
if task.status != TaskStatus.RUNNING:
console.print(
f"[{DIM}]task {escape(task.task_id)} already finished (status: {task.status.value}).[/]"
)
return True
task.request_cancel()
if task.kind == TaskKind.INVESTIGATION:
console.print(
f"[{WARNING}]cancellation signaled.[/] "
f"[{DIM}]if the investigation is still streaming, press[/] [bold]Ctrl+C[/bold] "
f"[{DIM}]to interrupt the current run.[/]"
)
else:
console.print(
f"[{HIGHLIGHT}]stop requested[/] "
f"[{DIM}]for {escape(task.kind.value)} {escape(task.task_id)}.[/] "
f"[{DIM}]use[/] [{HIGHLIGHT}]/tasks[/] [{DIM}]to confirm status.[/]"
)
return True
COMMANDS: list[SlashCommand] = [
SlashCommand("/history", "Show persisted command history.", _cmd_history),
SlashCommand(
"/tasks",
"List recent and in-flight shell tasks.",
_cmd_tasks,
usage=("/tasks",),
),
SlashCommand(
"/cancel",
"Cancel a running task by id.",
_cmd_cancel,
usage=("/cancel <task_id>",),
notes=("Use /tasks to list task ids.",),
validate_args=_validate_cancel_args,
),
SlashCommand(
"/stop",
"Show how to stop in-flight investigations and background tasks.",
_cmd_stop,
),
]
__all__ = ["COMMANDS"]
@@ -0,0 +1,107 @@
"""Slash command: interactive theme selection and persistence."""
from __future__ import annotations
import time
from rich.console import Console
from platform.terminal import theme as ui_theme
from platform.terminal.theme import (
get_active_theme_name,
list_theme_names,
set_active_theme,
)
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui.components.choice_menu import (
repl_choose_one,
repl_tty_interactive,
)
def _refresh_prompt_style(session: Session) -> None:
"""Defer prompt-toolkit style refresh until the next prompt_async turn."""
session.terminal.pending_theme_refresh = True
def _settle_and_drain_cpr() -> None:
"""Let in-flight terminal CPR replies land, then discard them from stdin."""
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
time.sleep(0.05)
drain_stale_cpr_bytes()
def _persist_and_report_theme(
session: Session,
console: Console,
selected: str,
) -> None:
from surfaces.cli.commands.config import _load_config, _save_config, _set_nested_key
from surfaces.interactive_shell.ui.components.rendering import refresh_welcome_poster
active = set_active_theme(selected)
session.terminal.active_theme_name = active.name
updated = _set_nested_key(_load_config(), "interactive.theme", active.name)
_save_config(updated)
# Poster redraw and prompt invalidation both trigger prompt_toolkit DSR/CPR
# queries under patch_stdout. Drain between each step so bytes never leak into
# the next prompt buffer (e.g. ``^[[1;1Rtheme set: pink``).
_settle_and_drain_cpr()
refresh_welcome_poster(console, session=session, theme_notice=active.name)
_settle_and_drain_cpr()
_refresh_prompt_style(session)
def _cmd_theme(session: Session, console: Console, args: list[str]) -> bool:
if args:
selected = args[0].strip().lower()
if selected not in list_theme_names():
supported = ", ".join(list_theme_names())
console.print(f"[{ui_theme.ERROR}]unknown theme:[/] {selected} (choose: {supported})")
return True
_persist_and_report_theme(session, console, selected)
return True
if not repl_tty_interactive():
console.print(f"[{ui_theme.DIM}]/theme requires an interactive TTY session.[/]")
return True
current = get_active_theme_name()
session.terminal.active_theme_name = current
choices = [
(name, f"{name}{' (current)' if name == current else ''}") for name in list_theme_names()
]
picked = repl_choose_one(
title="theme",
breadcrumb="/theme",
choices=choices,
initial_value=current,
)
if picked is None:
console.print(f"[{ui_theme.DIM}]theme unchanged.[/]")
return True
_persist_and_report_theme(session, console, picked)
return True
_THEME_FIRST_ARGS: tuple[tuple[str, str], ...] = tuple(
(name, "interactive palette") for name in list_theme_names()
)
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/theme",
"Choose and persist the interactive shell color theme.",
_cmd_theme,
usage=("/theme", "/theme <name>"),
examples=("/theme webflux", "/theme sunset", "/theme gruvbox"),
first_arg_completions=_THEME_FIRST_ARGS,
)
]
__all__ = ["COMMANDS"]
@@ -0,0 +1,44 @@
"""Slash command /tools."""
from __future__ import annotations
from rich.console import Console
from surfaces.interactive_shell.command_registry.types import (
SlashCommand,
make_list_root_handler,
)
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui import render_tools_table
from surfaces.interactive_shell.ui.tables.tool_catalog import build_tool_catalog
def _list_tools(_session: Session, console: Console, _args: list[str]) -> bool:
render_tools_table(console, build_tool_catalog())
return True
_cmd_tools = make_list_root_handler(
"/tools",
_list_tools,
list_aliases=("list", "ls", "tool", "tools"),
)
_TOOLS_FIRST_ARGS: tuple[tuple[str, str], ...] = (
("list", "list registered tools (investigation + chat surfaces)"),
("ls", "alias for list"),
("tool", "alias for list"),
("tools", "alias for list"),
)
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/tools",
"List registered tools.",
_cmd_tools,
usage=("/tools", "/tools list"),
first_arg_completions=_TOOLS_FIRST_ARGS,
)
]
__all__ = ["COMMANDS", "_TOOLS_FIRST_ARGS", "_cmd_tools"]
@@ -0,0 +1,68 @@
"""Slash-command type definitions."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from rich.console import Console
from rich.markup import escape as _rich_escape
from platform.terminal.theme import ERROR
from surfaces.interactive_shell.runtime import Session
@dataclass(frozen=True)
class SlashCommand:
name: str
description: str
handler: Callable[[Session, Console, list[str]], bool]
usage: tuple[str, ...] = ()
examples: tuple[str, ...] = ()
notes: tuple[str, ...] = ()
#: Tab-completion hints for the first argument after the command name (keyword, meta text).
first_arg_completions: tuple[tuple[str, str], ...] = ()
#: Optional pre-policy arg validator. Returns ``None`` if args are valid, or
#: a user-facing error string (rendered via ``console.print``) to short-circuit
#: dispatch with no policy prompt and no handler invocation.
validate_args: Callable[[list[str]], str | None] | None = None
#: Multi-sentence description for LLM planners; falls back to ``description``.
llm_description: str = ""
#: Natural-language triggers that should select this command.
use_cases: tuple[str, ...] = ()
#: Requests that look similar but should NOT use this command.
anti_examples: tuple[str, ...] = ()
#: JSON Schema for positional args after the command name (optional override).
args_schema: dict[str, Any] | None = None
def make_list_root_handler(
command_name: str,
list_handler: Callable[[Session, Console, list[str]], bool],
*,
list_aliases: tuple[str, ...] = ("list", "ls"),
) -> Callable[[Session, Console, list[str]], bool]:
"""Build a root handler that accepts list aliases and delegates to *list_handler*.
Bare invocation (no args) defaults to ``list``. Unknown subcommands
print a hint pointing at ``/<command> list``.
"""
aliases = frozenset(list_aliases)
def _root(session: Session, console: Console, args: list[str]) -> bool:
sub = (args[0].lower() if args else "list").strip()
if sub in aliases:
return list_handler(session, console, args[1:])
console.print(
f"[{ERROR}]unknown subcommand:[/] {_rich_escape(sub)} "
f"(try [bold]{command_name} list[/bold])"
)
session.mark_latest(ok=False, kind="slash")
return True
return _root
__all__ = ["SlashCommand", "make_list_root_handler"]
@@ -0,0 +1,356 @@
"""Slash commands: /watch, /watches, /unwatch."""
from __future__ import annotations
import os
import re
import threading
from dataclasses import dataclass
from rich.console import Console
from rich.markup import escape
from integrations.telegram.alarms import AlarmDispatcher
from integrations.telegram.credentials import load_credentials_from_env
from platform.common.errors import OpenSREError
from surfaces.interactive_shell.command_registry.types import (
SlashCommand,
)
from surfaces.interactive_shell.runtime import Session, TaskKind, TaskRecord, TaskStatus
from surfaces.interactive_shell.ui import (
BOLD_BRAND,
DIM,
ERROR,
HIGHLIGHT,
WARNING,
print_repl_table,
repl_table,
)
from surfaces.interactive_shell.ui.components.time_format import format_repl_timestamp
from tools.system.fleet_monitoring.probe import pid_exists
from tools.system.watch_dog.monitor import start_watchdog_daemon_thread
_PID_IN_COMMAND_RE = re.compile(r"pid=(\d+)")
_CONSOLE_PRINT_LOCK = threading.Lock()
@dataclass(frozen=True)
class WatchdogStartSpec:
pid: int
max_cpu: float | None = None
max_runtime_seconds: float | None = None
max_rss_mib: float | None = None
cooldown_seconds: float = 300.0
interval_seconds: float = 2.0
once: bool = False
def _parse_duration_seconds(raw: str) -> float | None:
text = raw.strip().lower()
if not text:
return None
mult = 1.0
if text.endswith("h"):
mult = 3600.0
text = text[:-1]
elif text.endswith("m") and not text.endswith("ms"):
mult = 60.0
text = text[:-1]
elif text.endswith("s"):
mult = 1.0
text = text[:-1]
try:
return float(text) * mult
except ValueError:
return None
def _parse_mib(raw: str) -> float | None:
text = raw.strip().lower()
if not text:
return None
mult = 1.0
if text.endswith("gib") or text.endswith("gb") or text.endswith("g"):
mult = 1024.0
for suffix in ("gib", "gb", "g"):
if text.endswith(suffix):
text = text[: -len(suffix)]
break
elif text.endswith("mib") or text.endswith("mb") or text.endswith("m"):
mult = 1.0
for suffix in ("mib", "mb", "m"):
if text.endswith(suffix):
text = text[: -len(suffix)]
break
try:
return float(text) * mult
except ValueError:
return None
def parse_watch_argv(argv: list[str]) -> WatchdogStartSpec | str:
"""Parse ``/watch`` arguments (tokens after the command name)."""
if not argv:
return (
f"[{ERROR}]usage:[/] /watch <pid> [--max-cpu N] [--max-runtime D] [--max-rss S] "
f"[--cooldown D] [--interval N] [--once]"
)
if argv[0].startswith("-"):
return f"[{ERROR}]usage:[/] /watch <pid> ... — the process id must come first"
try:
pid = int(argv[0], 10)
except ValueError:
return f"[{ERROR}]invalid pid:[/] {escape(argv[0])}"
if pid <= 0:
return f"[{ERROR}]pid must be a positive integer:[/] {pid}"
max_cpu: float | None = None
max_runtime_seconds: float | None = None
max_rss_mib: float | None = None
cooldown_seconds = 300.0
interval_seconds = 2.0
once = False
i = 1
while i < len(argv):
token = argv[i]
if token == "--once":
once = True
i += 1
continue
if not token.startswith("--"):
return f"[{ERROR}]unexpected argument:[/] {escape(token)}"
if i + 1 >= len(argv):
return f"[{ERROR}]missing value for[/] {escape(token)}"
value = argv[i + 1]
i += 2
if token == "--max-cpu":
try:
pct = float(value)
except ValueError:
return f"[{ERROR}]invalid --max-cpu:[/] {escape(value)}"
max_supported_cpu = 100.0 * float(max(1, os.cpu_count() or 1))
if pct <= 0 or pct > max_supported_cpu:
return f"[{ERROR}]--max-cpu must be between 0 and {max_supported_cpu:g}:[/] {pct}"
max_cpu = pct
elif token == "--max-runtime":
seconds = _parse_duration_seconds(value)
if seconds is None or seconds <= 0:
return f"[{ERROR}]invalid --max-runtime:[/] {escape(value)}"
max_runtime_seconds = seconds
elif token == "--max-rss":
mib = _parse_mib(value)
if mib is None or mib <= 0:
return f"[{ERROR}]invalid --max-rss:[/] {escape(value)}"
max_rss_mib = mib
elif token == "--cooldown":
seconds = _parse_duration_seconds(value)
if seconds is None or seconds <= 0:
return f"[{ERROR}]invalid --cooldown:[/] {escape(value)}"
cooldown_seconds = seconds
elif token == "--interval":
seconds = _parse_duration_seconds(value)
if seconds is None or seconds <= 0:
return f"[{ERROR}]invalid --interval:[/] {escape(value)}"
interval_seconds = seconds
else:
return f"[{ERROR}]unknown flag:[/] {escape(token)}"
return WatchdogStartSpec(
pid=pid,
max_cpu=max_cpu,
max_runtime_seconds=max_runtime_seconds,
max_rss_mib=max_rss_mib,
cooldown_seconds=cooldown_seconds,
interval_seconds=interval_seconds,
once=once,
)
def _watch_command_summary(spec: WatchdogStartSpec) -> str:
parts = [f"watchdog pid={spec.pid}"]
if spec.max_cpu is not None:
parts.append(f"max_cpu={spec.max_cpu:g}%")
if spec.max_runtime_seconds is not None:
parts.append(f"max_runtime={spec.max_runtime_seconds:g}s")
if spec.max_rss_mib is not None:
parts.append(f"max_rss={spec.max_rss_mib:g}MiB")
parts.append(f"cooldown={spec.cooldown_seconds:g}s")
parts.append(f"interval={spec.interval_seconds:g}s")
if spec.once:
parts.append("once")
return " ".join(parts)
def _watched_pid_from_task(task: TaskRecord) -> str:
if task.command:
match = _PID_IN_COMMAND_RE.search(task.command)
if match:
return match.group(1)
return ""
def _cmd_watch(session: Session, console: Console, args: list[str]) -> bool:
parsed = parse_watch_argv(args)
if isinstance(parsed, str):
console.print(parsed)
return True
if not pid_exists(parsed.pid):
console.print(f"[{ERROR}]no such process:[/] pid {parsed.pid}")
return True
try:
creds = load_credentials_from_env()
except OpenSREError as exc:
console.print(f"[{ERROR}]{escape(str(exc))}[/]")
return True
summary = _watch_command_summary(parsed)
task = session.task_registry.create(TaskKind.WATCHDOG, command=summary)
task.mark_running()
task.attach_pid(parsed.pid)
dispatcher = AlarmDispatcher(creds, cooldown_seconds=parsed.cooldown_seconds)
def _on_alarm(threshold: str, detail: str) -> None:
with _CONSOLE_PRINT_LOCK:
console.print(
f"[task {escape(task.task_id)}] alarm fired: {escape(threshold)} "
f"{escape(detail)} (telegram delivered)"
)
start_watchdog_daemon_thread(
task=task,
watched_pid=parsed.pid,
interval_seconds=parsed.interval_seconds,
max_cpu=parsed.max_cpu,
max_runtime_seconds=parsed.max_runtime_seconds,
max_rss_mib=parsed.max_rss_mib,
once=parsed.once,
dispatcher=dispatcher,
on_alarm=_on_alarm,
)
console.print(f"[{DIM}]task[/] [bold]{escape(task.task_id)}[/bold] [{DIM}]started.[/]")
return True
def _cmd_watches(session: Session, console: Console, _args: list[str]) -> bool:
rows = [t for t in session.task_registry.list_recent(n=100) if t.kind == TaskKind.WATCHDOG]
if not rows:
console.print(f"[{DIM}]no watchdog tasks in this session.[/]")
return True
table = repl_table(title="Watchdogs\n", title_style=BOLD_BRAND)
table.add_column("id", style="bold")
table.add_column("pid", justify="right")
table.add_column("kind")
table.add_column("status")
table.add_column("started", style=DIM)
table.add_column("thresholds", overflow="fold")
table.add_column("last sample", style=DIM, overflow="fold")
status_style = {
TaskStatus.RUNNING: WARNING,
TaskStatus.COMPLETED: HIGHLIGHT,
TaskStatus.CANCELLED: WARNING,
TaskStatus.FAILED: ERROR,
TaskStatus.PENDING: DIM,
}
for task in rows:
st = status_style.get(task.status, DIM)
started = format_repl_timestamp(task.started_at, style="utc")
thresholds = task.command or ""
sample = task.progress or ""
table.add_row(
task.task_id,
_watched_pid_from_task(task),
TaskKind.WATCHDOG.value,
f"[{st}]{task.status.value}[/]",
started,
escape(thresholds),
escape(sample),
)
print_repl_table(console, table)
return True
def _validate_unwatch_args(args: list[str]) -> str | None:
if not args:
return f"[{ERROR}]usage:[/] /unwatch <task_id> — use [{HIGHLIGHT}]/watches[/] to list ids"
return None
def _cmd_unwatch(session: Session, console: Console, args: list[str]) -> bool:
needle = args[0]
candidates = session.task_registry.candidates(needle)
if not candidates:
console.print(f"[{ERROR}]no task matches id:[/] {escape(needle)}")
return True
if len(candidates) > 1:
console.print(
f"[{ERROR}]ambiguous id prefix:[/] {escape(needle)} "
f"[{DIM}]({len(candidates)} matches — use a longer prefix)[/]"
)
return True
task = candidates[0]
if task.kind != TaskKind.WATCHDOG:
console.print(
f"[{ERROR}]task {escape(task.task_id)} is not a watchdog "
f"(kind: {escape(task.kind.value)}); use /cancel instead.[/]"
)
return True
if task.status != TaskStatus.RUNNING:
console.print(
f"[{DIM}]task {escape(task.task_id)} already finished (status: {task.status.value}).[/]"
)
return True
task.request_cancel()
console.print(
f"[{HIGHLIGHT}]stop requested[/] "
f"[{DIM}]for watchdog {escape(task.task_id)}.[/] "
f"[{DIM}]use[/] [{HIGHLIGHT}]/watches[/] [{DIM}]to confirm status.[/]"
)
return True
def _validate_watch_args(args: list[str]) -> str | None:
if not args:
return (
f"[{ERROR}]usage:[/] /watch <pid> [--max-cpu N] [--max-runtime D] [--max-rss S] "
f"[--cooldown D] [--interval N] [--once]"
)
return None
COMMANDS: list[SlashCommand] = [
SlashCommand(
"/watch",
"Watch a process and send threshold alarms.",
_cmd_watch,
usage=(
"/watch <pid> [--max-cpu N] [--max-runtime D] [--max-rss S] "
"[--cooldown D] [--interval N] [--once]",
),
notes=("Alarms are sent to Telegram when Telegram delivery is configured.",),
validate_args=_validate_watch_args,
),
SlashCommand(
"/watches",
"List watchdog background tasks with the latest resource sample.",
_cmd_watches,
usage=("/watches",),
),
SlashCommand(
"/unwatch",
"Cancel a running watchdog task by id.",
_cmd_unwatch,
usage=("/unwatch <task_id>",),
notes=("Use /watches to list watchdog task ids.",),
validate_args=_validate_unwatch_args,
),
]
__all__ = ["COMMANDS", "WatchdogStartSpec", "parse_watch_argv"]