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
+275
View File
@@ -0,0 +1,275 @@
# Interactive shell package
These instructions apply to `interactive_shell/` and all of its
subdirectories. The repo-root `AGENTS.md` still applies.
## Purpose
`interactive_shell/` owns the interactive OpenSRE terminal surface: the REPL
loop, slash-command surface, local alert ingestion, shell execution, and Rich /
prompt-toolkit UI. Reusable agent session state, prompt history, grounding, and
prompt construction live under `core.agent`.
Design for a terminal user who may be in the middle of an incident: behavior
should be predictable, interruptible, explainable, and safe by default.
## Package map and ownership
| Area | Owns | Keep out |
| --- | --- | --- |
| `main.py` | process/bootstrap boundary for starting the REPL | per-turn dispatch/runtime logic |
| `controller.py` | top-level REPL wiring, alert listener lifecycle, prompt loop, background workers, and shutdown | feature-specific business logic or compatibility-only forwarding |
| `runtime/core/turn_accounting.py` | shell turn accounting (`ShellTurnAccounting`) for analytics, telemetry, recorder flush, turn persistence, and intent stamps | turn-flow control (owned by `core.agent_harness`) or tool-calling turn execution |
| `command_registry/` | slash-command definitions, argument validation, command dispatch | long-running implementation details better placed in services/runtime modules |
| `runtime/` | background task workers, lifecycle/`ReplState`, runtime context assembly, semantic shell-turn execution, and core harness adapters | prompt text, reusable session persistence, or compatibility shims |
| `tools/interactive_shell/shell/` | shell command parsing, shell execution policy, subprocess execution, and the `run_shell_command`/`run_cd`/`run_pwd` runner (next to the `shell_run` tool in `tools/interactive_shell/actions/shell.py`) | slash-command execution |
| `references/` | CLI/docs/source/AGENTS reference loading and caching | generated model prose |
| `config/` | interactive-shell config loading and tool catalog metadata | global app config unrelated to the REPL |
| `ui/` | Rich/prompt-toolkit rendering, theme, menus, streaming output, and domain views such as `incoming_alerts.py` (receiver/queue/listener lifecycle lives in `core.domain.alerts.inbox`) | business logic or network calls |
When a change crosses these boundaries, prefer extracting a small helper in the
owning area rather than adding more logic to the caller.
## Cross-cutting rules
- Treat every external input as untrusted: user prompt text, slash-command args,
alert payloads, files read into prompts, history, subprocess output, model
output, and integration metadata.
- Keep the interactive path responsive. Long-running work must be cancellable,
timeout-bounded, moved off the input path, or surfaced with clear progress.
- Preserve import-time lightness. Do not start threads, call LLMs, read large
files, or contact networks at module import time.
- Prefer explicit data models and typed helpers over loosely shaped dictionaries
when data crosses submodule boundaries.
- Keep user-visible strings intentional. Slash-command names, flags, output
labels, prompts, response bodies, and error wording are user-facing API.
- Avoid new module-level mutable globals. If global coordination is unavoidable,
provide deterministic reset/cleanup hooks and test isolation.
- Do not keep compatibility-only forwarding modules after moving code. Migrate
callers/tests to the canonical owner and remove the old import path in the
same change.
## Slash commands
- Add commands as `SlashCommand` entries in the relevant `command_registry/*`
module. Keep handlers small: parse args, call focused helpers, render result.
- **REPL + CLI parity (required):** Every command in `SLASH_COMMANDS` must have a
matching `_MCP_BY_COMMAND` entry in
`command_registry/slash_catalog.py`. That catalog feeds the LLM planner (`slash_invoke`),
planner tool specs, and compact help text. Without it, CI fails
(`test_slash_catalog_covers_all_registered_commands`).
- **New REPL-only slash command:** add `SlashCommand` in the owning
`command_registry/*` module **and** `_mcp(...)` in `slash_catalog.py` (keep
keys sorted alphabetically in `_MCP_BY_COMMAND`).
- **New CLI with REPL parity:** add the Click command under `surfaces/cli/commands/`,
register a `SlashCommand` in `command_registry/cli_parity.py` (subprocess to
`opensre …`), **and** add `_MCP_BY_COMMAND` in `slash_catalog.py` with
`llm_description`, `use_cases`, and `anti_examples` aligned to the commands
`usage` tuple.
- **Verify before push:**
`uv run python -m pytest tests/interactive_shell/command_registry/test_slash_catalog.py -q`
- Use `validate_args` for cheap pre-policy validation so bad arguments do not
trigger confirmations or side effects.
- Send command execution through the central dispatch and execution-policy
helpers. Do not bypass `execution_policy.py` for new commands.
- **Alpha allow-all execution policy (current behavior):** the REPL runs with
**no command guardrails**. `execution_policy.py` resolves every action to
`allow` with **no confirmation prompt** — all slash/`opensre` commands,
investigations, synthetic tests, code-agent launches, LLM runtime switches, and
**all** shell commands run immediately, in any context (TTY or not, trust mode
or not). There is **no shell-command safety policy**: the
read-only/mutating/restricted classification and the `deny` floor were removed
(`shell_policy.py` deleted; parsing/policy/execution live under
`tools/shell/`). Mutating commands (`rm`/`mv`/`docker`), `restricted` commands
(`sudo`, `systemctl`, `kill`, `dd`, …), shell operators (`| && ; > <`), and
command substitution all run; the `!` prefix is honored but optional. The only
shell input still rejected is genuinely empty input (a bare `!` or whitespace).
Do **not** re-add a shell allowlist or deny floor while in alpha — see
`docs/interactive-shell-action-policy.md`. The former `ExecutionTier`
classification was removed because it gated nothing under default-allow; if an
opt-in stricter policy is reintroduced after alpha, gate it in
`execution_policy.py` (the `ask` verdict, confirmation UX, and `trust_mode` are
retained as the hook), not via a planner-stage denial.
- Non-TTY behavior under default-allow: actions no longer fail closed on
non-interactive stdin (there is nothing to confirm). The fail-closed path only
applies if a verdict is explicitly `ask`, which the default policy does not
emit.
- **CPR / exclusive-stdin registration (required for table-outputting commands):**
Under `patch_stdout(raw=True)`, the REPL runs dispatch concurrently with the
next `prompt_async()`. When a command emits Rich table output, prompt_toolkit
redraws the prompt mid-flight, sending an `ESC[6n` DSR query; the terminal's
CPR response (`ESC[<row>;<col>R`) arrives as literal keystrokes in the incoming
prompt buffer, causing garbage like `^[[60;1R` to appear.
**Any command that calls `print_repl_table` (directly or via `render_table` /
`render_integrations_table` / `render_models_table` / etc.) must be added to
`_EXCLUSIVE_STDIN_MENU_COMMANDS` in `runtime/utils/input_policy.py`.** That makes the main
loop call `await state.queue.join()`, blocking the next prompt until dispatch
completes and both drain cycles clean up stale CPR bytes before the next
`prompt_async()` starts.
- **How to check:** after adding a command, run it in the REPL and type a few
characters in the next prompt. If no `^[[…R` garbage appears, the registration
is correct.
- **Agent-selected interactive commands:** `_EXCLUSIVE_STDIN_MENU_COMMANDS`
only reserves stdin for literal `/slash` command text that
`_literal_slash_command_text` recognizes. When free text like
"remove github" is resolved by the action agent into an inline-picker
command (`/integrations remove`, `/integrations setup`, `/mcp connect`,
`/mcp disconnect`, or a bare `/integrations` / `/mcp` menu), the loop has not
reserved stdin, so `tools/interactive_shell/actions/slash.py` must NOT run the picker inline. It defers
via `session.queue_auto_command(...)`, which re-submits the command as
literal command text so the loop can reserve exclusive stdin before the
agent path runs it. New raw-stdin picker/wizard commands the action agent can emit
must be added to
`_INTERACTIVE_PICKER_MENUS` / `_INTERACTIVE_PICKER_SUBCOMMANDS` in
`tools/interactive_shell/actions/slash.py`.
## Action Selection And Execution
- **Hard boundary:** do not add regex/keyword/fuzzy intent routing for natural
language, or any deterministic mapping from non-`/`-prefixed prose to an action
(e.g. "show integrations" -> `/integrations`). Engineers have been fired before
for reintroducing intent heuristics that compete with the action agent.
- **Sanctioned exception:** input the user types as a literal `/slash` command
is dispatched deterministically (a static `slash_invoke` call in
`core/agent_harness/turns/action_driver.py`), so slash commands keep working when the
action-agent LLM is unavailable. This is an explicit-command bypass, not
intent inference — it fires only when the message *is* a `/command`, and
free-form text is still LLM-selected. See
`docs/interactive-shell-action-policy.md` ("Deterministic literal-`/slash`
dispatch").
- **No planning-stage fail-closed safeguard (v0.1 decision).** The second-phase
action agent never denies a turn. Because every terminal action is read-only,
an unmatched/ambiguous/chatty clause is not a safety risk — the agent executes
the clauses it can map and lets the rest fall through to the conversational
assistant. We removed the `denied` decision path, the `mark_unhandled` planner
tool, the `UNHANDLED:` convention, and the "I couldn't safely decide actions"
message because they caused frequent false denials (e.g. a conversational
question that embedded a quoted, list-style directive) with no safety upside.
Details and rationale live in `core/agent_harness/AGENTS.md`. If
mutating actions are ever introduced, gate them with the
execution-stage confirmation policy (`tools/shared/execution_policy.py`), not a
planner-stage denial.
- Keep deterministic command detection in `orchestration/` for terminal UI
policy only; use the action agent for slash/tool action selection.
- Send uncertainty to a safe surface: help/chat or a clarification, not direct
mutation or shell execution.
- LLM-generated text must never execute directly. Convert proposed actions into
explicit planned actions, show them to the user when appropriate, then execute
through `orchestration/` and policy gates.
- Keep action summaries human-readable and specific enough for confirmation UX
and audit logs.
- When adding a new action type, test allowed, denied, and confirmation-required
paths.
## LLM prompts, grounding, and references
- Keep prompts bounded. Enforce size caps for docs, source chunks, histories,
observations, alert text, and command output included in model context.
- Ground procedural/help answers in maintained references (`docs/`, CLI help,
AGENTS files, source snippets). If references do not support an answer, say so
rather than inventing steps.
- Do not include secrets in prompts. Redact or omit tokens, auth headers, env
values, local credentials, and raw integration config.
- Keep prompt rules reusable in `core.agent_harness.prompts` so chat/help/action
surfaces use consistent terminology and formatting.
- Reference caches should be deterministic, invalidatable when source files
change, and cheap to rebuild in tests.
## Terminal UI and rendering
- Escape user-controlled content before passing it to Rich markup
(`rich.markup.escape`): alerts, command output, file paths, integration names,
model/provider labels, errors, docs snippets, and model text that is not
already intentionally rendered as Markdown.
- Use semantic tokens from `ui/theme.py`. Do not introduce raw hex colors, Rich
named colors, or raw ANSI escapes outside `ui/theme.py` unless a narrow
prompt-toolkit compatibility path requires it.
- Keep rendering helpers as pure as practical: accept data, return/render Rich
objects, avoid reading config or mutating session state from UI modules.
- Any raw terminal-mode code must check TTY support and restore terminal state
in `finally`.
- Be careful mixing `prompt_toolkit.patch_stdout`, Rich live rendering, and
background output. Prefer append-only, paragraph-buffered, or throttled
rendering paths that do not corrupt the editable prompt.
- UI changes should handle narrow terminals, non-ASCII fallback where relevant,
long text, empty states, and non-TTY automation.
## Shell, subprocesses, and local system effects
- Shell execution changes belong under `shell/` and must preserve parsing,
quoting, timeout, redaction, and policy behavior.
- Treat subprocess output as untrusted display text; escape it before Rich
markup and cap what is retained or sent to prompts.
- Use explicit timeouts and clear cancellation behavior for subprocesses. Avoid
waits that can hang the REPL indefinitely.
- Keep allow/deny decisions explainable. If a command is blocked, return a
user-facing reason and a safe alternative when possible.
## State, history, config, and background work
- Prefer explicit `Session` fields for session state. Keep ownership clear:
runtime owns lifecycle, history owns persistence, config owns shell-specific
settings.
- Background threads/tasks/listeners must have deterministic shutdown. Tests
should stop handles and workers in fixtures or `finally` blocks.
- Protect shared queues and mutable session data with locks or single-owner
discipline. Avoid check-then-act races around queues, cancellation flags,
current tasks, and listener handles.
- History should avoid storing secrets or excessive payloads. Apply truncation
and privacy policy consistently.
- Config loading should degrade gracefully with actionable errors; do not make
the REPL unusable because an optional config or catalog source is missing.
## External input and local listener safety
- Network-ish local surfaces such as `core.domain.alerts.inbox` (started by the
REPL entrypoint) must validate cheap request metadata before blocking reads or
expensive parsing.
- Never perform unbounded request-body reads. For alert POSTs specifically,
validate `Content-Length` first, and only then read the bounded body:
- non-numeric `Content-Length` values make `int(...)` raise `ValueError`;
catch this and return `400`.
- negative lengths must return `400`; `rfile.read(-1)` reads until EOF rather
than zero bytes, which can stall the single-threaded handler.
- oversized positive lengths must return `413` without attempting to read the
advertised body.
- Preserve clean unauthorized responses for real POST bodies by draining only a
bounded body before returning `401`; this avoids close-with-unread-data resets
on some platforms without allowing oversized pre-auth reads.
- Keep request-size and malformed-header checks effective for both authenticated
and unauthenticated callers.
- Keep non-loopback listener binding protected by a token. Use constant-time
token comparison and never log bearer tokens, raw auth headers, or full alert
payloads.
## Testing expectations
- Put tests under `tests/interactive_shell/`, mirroring the package area
when useful (`orchestration/`, `ui/`, etc.). Never add tests under
source packages.
- For focused changes, run the closest tests, for example:
- `uv run python -m pytest tests/core/domain/alerts/test_inbox.py`
- `uv run python -m pytest tests/interactive_shell/<area>/`
- `uv run python -m pytest tests/interactive_shell/`
- Add regression tests for incident-prone edges: platform socket behavior,
malformed input, non-TTY execution, cancellation, policy denial, prompt-size
caps, Rich escaping, and background cleanup.
- Prefer deterministic tests over sleep-heavy tests. Use fake classifiers,
fake sessions, fake consoles, monkeypatched subprocesses, and small fixtures.
- For UI work, test pure formatting/rendering helpers where possible and keep
full REPL-loop tests minimal.
- For action-planning or execution-policy changes, test both safe fallback behavior and
the intended positive path.
## Change checklist
Before considering an interactive-shell change complete, check:
1. Is the logic in the right submodule, with import-time side effects avoided?
2. Is user-facing behavior preserved or intentionally documented?
3. Are unsafe actions sent through execution policy with the correct tier?
4. Are external inputs bounded, escaped, redacted, and timeout-protected?
5. Do background resources shut down deterministically?
6. Are focused tests added or updated under `tests/interactive_shell/`?
7. If `SLASH_COMMANDS` changed, does `slash_catalog.py` include every command
(REPL and `cli_parity`)? Run `test_slash_catalog.py`.
+14
View File
@@ -0,0 +1,14 @@
"""Interactive REPL for OpenSRE — Claude Code-style incident response terminal."""
from __future__ import annotations
from typing import Any
def run_repl(*args: Any, **kwargs: Any) -> Any:
from surfaces.interactive_shell.main import run_repl as runtime_run_repl
return runtime_run_repl(*args, **kwargs)
__all__ = ["run_repl"]
@@ -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"]
+268
View File
@@ -0,0 +1,268 @@
"""Top-level interactive-shell controller: prompt input, dispatch, and shutdown."""
from __future__ import annotations
import asyncio
import logging
import os
from collections.abc import Iterator
from contextlib import contextmanager
from prompt_toolkit import PromptSession
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console
from config.repl_config import ReplConfig
from core.domain.alerts import inbox as _alert_inbox
from surfaces.interactive_shell.runtime.background.workers import BackgroundTaskManager
from surfaces.interactive_shell.runtime.context import (
ReplRuntimeContext,
create_repl_runtime_context,
)
from surfaces.interactive_shell.runtime.core.prompt_manager import PromptManager
from surfaces.interactive_shell.runtime.core.state import (
ReplState,
SpinnerState,
)
from surfaces.interactive_shell.runtime.input import (
PromptInputReader,
)
from surfaces.interactive_shell.runtime.input.actions import (
CancelTurn,
CloseShell,
DeliverConfirmation,
IgnoreInput,
InputAction,
SubmitTurn,
)
from surfaces.interactive_shell.runtime.turn_host import (
AgentTurnRuntime,
run_agent_turn,
run_agent_turn_queue,
run_input_loop,
)
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui import DIM
log = logging.getLogger(__name__)
@contextmanager
def _alert_listener_token(token: str | None) -> Iterator[None]:
"""Install the configured alert token for this listener, restoring any prior value."""
key = "OPENSRE_ALERT_LISTENER_TOKEN"
previous = os.environ.get(key)
try:
if token:
os.environ[key] = token
else:
os.environ.pop(key, None)
yield
finally:
if previous is None:
os.environ.pop(key, None)
else:
os.environ[key] = previous
@contextmanager
def _alert_listener(
cfg: ReplConfig | None,
console: Console,
*,
existing: _alert_inbox.AlertInbox | None = None,
) -> Iterator[_alert_inbox.AlertInbox | None]:
if existing is not None:
yield existing
return
if cfg is None or not cfg.alert_listener_enabled:
yield None
return
from gateway.web_server import WebAppServerHandle, serve_webapp_in_thread
inbox: _alert_inbox.AlertInbox | None = None
handle: WebAppServerHandle | None = None
try:
inbox = _alert_inbox.AlertInbox()
_alert_inbox.set_current_inbox(inbox)
with _alert_listener_token(cfg.alert_listener_token):
handle = serve_webapp_in_thread(
host=cfg.alert_listener_host,
port=cfg.alert_listener_port,
)
console.print(f"[{DIM}]listening for alerts on http://{handle.bound_address}/alerts[/]")
try:
yield inbox
finally:
if handle is not None:
handle.stop()
_alert_inbox.set_current_inbox(None)
except Exception as exc:
log.warning("Alert listener could not start: %s — continuing without it.", exc)
_alert_inbox.set_current_inbox(None)
yield None
def _resolve_runtime_context(
session: Session | ReplRuntimeContext | None,
*,
state: ReplState | None,
spinner: SpinnerState | None,
pt_session: PromptSession[str] | None,
inbox: _alert_inbox.AlertInbox | None,
) -> ReplRuntimeContext:
if isinstance(session, ReplRuntimeContext):
if state is None and spinner is None and pt_session is None and inbox is None:
return session
return ReplRuntimeContext(
session=session.session,
state=state if state is not None else session.state,
spinner=spinner if spinner is not None else session.spinner,
pt_session=pt_session if pt_session is not None else session.pt_session,
inbox=inbox if inbox is not None else session.inbox,
)
return create_repl_runtime_context(
session,
state=state,
spinner=spinner,
pt_session=pt_session,
inbox=inbox,
)
class InteractiveShellController:
"""Coordinate prompt input, queued dispatch, background workers, and shutdown."""
def __init__(
self,
session: Session | ReplRuntimeContext | None = None,
*,
config: ReplConfig | None = None,
state: ReplState | None = None,
spinner: SpinnerState | None = None,
pt_session: PromptSession[str] | None = None,
inbox: _alert_inbox.AlertInbox | None = None,
console: Console | None = None,
) -> None:
self.runtime_context = _resolve_runtime_context(
session,
state=state,
spinner=spinner,
pt_session=pt_session,
inbox=inbox,
)
self.config = config
self.session = self.runtime_context.session
self.inbox = self.runtime_context.inbox
self.state = self.runtime_context.state
self.spinner = self.runtime_context.spinner
self.service_console = console or Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
self.prompt = PromptManager(
self.session,
self.state,
self.spinner,
self.runtime_context.pt_session,
)
self.turn_runtime = AgentTurnRuntime(
session=self.session,
state=self.state,
spinner=self.spinner,
invalidate_prompt=lambda: self.prompt.invalidate_prompt(),
request_exit=self.prompt.request_exit,
)
self.echo_console = Console(highlight=False, force_terminal=True, color_system="truecolor")
self.input_reader = PromptInputReader(
self.prompt,
self.state,
self.session,
self.echo_console,
)
self.background: BackgroundTaskManager | None = None
self.tasks: list[tuple[str, asyncio.Task[None]]] = []
async def start_interactive_shell(self) -> None:
with _alert_listener(self.config, self.service_console, existing=self.inbox) as inbox:
self.inbox = inbox
self.runtime_context.inbox = inbox
self._start_runtime_services()
try:
with patch_stdout(raw=True):
# Main input loop: reads prompts and enqueues submitted turns
# onto state.queue. The agent turns themselves run in
# run_agent_turn_queue, started above in _start_runtime_services.
await run_input_loop(
state=self.state,
session=self.session,
background=self.background,
input_reader=self.input_reader,
echo_console=self.echo_console,
handle_input_action=self._handle_input_action,
)
finally:
await self._shutdown_runtime()
def _start_runtime_services(self) -> None:
self.prompt.setup()
self.background = BackgroundTaskManager(
self.session,
self.state,
self.spinner,
self.inbox,
self.prompt.invalidate_prompt,
)
self.tasks = self.background.start_all(
lambda: run_agent_turn_queue(
state=self.state,
run_turn=lambda text: run_agent_turn(self.turn_runtime, text),
)
)
# Fleet sampler is lazy: /fleet triggers it on first live use.
self.session.terminal.fleet_sampler_starter = self.background.ensure_fleet_sampler_started
async def _handle_input_action(self, action: InputAction) -> bool:
match action:
case IgnoreInput():
return True
case CloseShell():
return False
case CancelTurn(submitted_text=text):
if text:
self.prompt.render_submitted_prompt(self.echo_console, text)
self.state.cancel_current_dispatch()
return True
case DeliverConfirmation(text=text):
self.state.deliver_confirmation(text)
return True
case SubmitTurn(text=text, wait_until_idle=wait, warning=warning):
if warning:
self.echo_console.print(warning)
self.prompt.render_submitted_prompt(self.echo_console, text)
await self.state.queue.put(text)
if wait:
await self.state.queue.join()
return True
raise AssertionError(f"Unhandled input action: {action!r}")
async def _shutdown_runtime(self) -> None:
self.state.request_exit()
self.state.cancel_current_dispatch()
for _label, task in self.tasks:
task.cancel()
shutdown_results = await asyncio.gather(
*(task for _label, task in self.tasks),
return_exceptions=True,
)
for (label, _task), result in zip(self.tasks, shutdown_results, strict=True):
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
log.debug("%s task shutdown raised exception: %s", label, result)
__all__ = ["InteractiveShellController"]
@@ -0,0 +1,360 @@
"""Reference text for OpenSRE interactive-shell CLI answers."""
from __future__ import annotations
import logging
import time
from collections.abc import Callable, Mapping
from typing import Any
import click
from core.agent_harness.grounding.diagnostics import (
GroundingSource,
log_grounding_cache_diagnostics,
)
from core.agent_harness.grounding.models import CacheStats
from core.agent_harness.prompts.prompt_context import DefaultPromptContextProvider
_logger = logging.getLogger(__name__)
_MAX_REFERENCE_CHARS = 28_000
# Heuristic: truncated or failed reference output must not be cached or the
# assistant would keep an empty reference for the whole process.
_MIN_CACHEABLE_CLI_REFERENCE_CHARS = 80
_CLI_REFERENCE_SENTINEL = "=== opensre --help ==="
SlashCommandProvider = Callable[[], Mapping[str, Any]]
CommandGroupProvider = Callable[[], click.Command | None]
def _is_cacheable_cli_reference(text: str) -> bool:
stripped = text.strip()
if len(stripped) < _MIN_CACHEABLE_CLI_REFERENCE_CHARS:
return False
return _CLI_REFERENCE_SENTINEL in text
def _slash_command_names(provider: SlashCommandProvider | None) -> list[str]:
if provider is None:
return []
return sorted(provider().keys())
def _current_cli_signature(
cli_group: click.Command | None,
slash_provider: SlashCommandProvider | None = None,
) -> str:
"""Stable signature of the CLI command surface and interactive slash commands.
Bumps cache when subcommands change, slash-command metadata changes, or the
installed package version changes.
"""
from config.version import get_opensre_version
if isinstance(cli_group, click.Group):
cmd_names = ",".join(sorted(cli_group.commands.keys()))
else:
cmd_names = ""
slash_names = ",".join(_slash_command_names(slash_provider))
return f"opensre={get_opensre_version()}|commands={cmd_names}|slash={slash_names}"
def _format_param(param: click.Parameter) -> str:
"""Return a compact, side-effect-free description of a Click parameter."""
if isinstance(param, click.Option):
names = ", ".join((*param.opts, *param.secondary_opts))
if not names:
names = param.name or "(option)"
value_hint = ""
if not param.is_flag and not param.count:
value_hint = " " + (param.metavar or param.name or "VALUE").upper()
default = ""
if param.show_default and param.default not in (None, "", ()):
default = f" [default: {param.default}]"
help_text = (param.help or "").strip()
return f" {names}{value_hint} - {help_text}{default}".rstrip()
if isinstance(param, click.Argument):
name = (param.name or "ARG").upper()
required = "" if param.required else " (optional)"
return f" {name}{required}"
return f" {param.name or type(param).__name__}"
def _format_command_reference(
command: click.Command,
*,
path: str,
include_subcommands: bool = True,
) -> str:
"""Render Click command metadata without invoking help callbacks.
``click.Command.get_help()`` eventually calls ``format_help()``. The root
OpenSRE group overrides that path to render via Rich's live console, which
can leak into the interactive terminal when the assistant builds grounding
context. This renderer inspects command objects directly instead.
"""
lines = [f"Usage: {path} [OPTIONS]"]
if isinstance(command, click.Group):
lines[0] += " COMMAND [ARGS]..."
elif command.params:
arg_names = [
(param.name or "ARG").upper()
for param in command.params
if isinstance(param, click.Argument)
]
if arg_names:
lines[0] += " " + " ".join(arg_names)
help_text = (command.help or command.short_help or "").strip()
if help_text:
lines.extend(["", help_text])
params = [param for param in command.params if not getattr(param, "hidden", False)]
if params:
lines.extend(["", "Options/Arguments:"])
lines.extend(_format_param(param) for param in params)
if include_subcommands and isinstance(command, click.Group):
command_rows: list[tuple[str, str]] = []
with click.Context(command, info_name=path.rsplit(" ", 1)[-1]) as ctx:
for name in command.list_commands(ctx):
subcommand = command.get_command(ctx, name)
if subcommand is None or subcommand.hidden:
continue
command_rows.append((name, subcommand.get_short_help_str(limit=160)))
if command_rows:
lines.extend(["", "Commands:"])
lines.extend(f" {name} - {summary}".rstrip() for name, summary in command_rows)
return "\n".join(lines).rstrip() + "\n"
def _build_cli_reference_text_uncached(
cli_group: click.Command | None,
slash_provider: SlashCommandProvider | None = None,
) -> str:
"""Build a side-effect-free CLI reference without invoking Click commands."""
parts: list[str] = []
if cli_group is None:
parts.append("=== opensre --help ===\n")
parts.append(
"The CLI command surface is not available in this runtime.\n"
"Launch the OpenSRE CLI (`opensre`) for command-level help.\n"
)
else:
parts.append("=== opensre --help ===\n")
parts.append(_format_command_reference(cli_group, path="opensre"))
if isinstance(cli_group, click.Group):
with click.Context(cli_group, info_name="opensre") as ctx:
for name in sorted(cli_group.commands.keys()):
command = cli_group.get_command(ctx, name)
if command is None or command.hidden:
continue
parts.append(f"\n=== opensre {name} --help ===\n")
parts.append(_format_command_reference(command, path=f"opensre {name}"))
parts.append("\n=== Interactive-shell slash commands ===\n")
parts.append(_interactive_shell_slash_hints(slash_provider))
text = "".join(parts)
if len(text) > _MAX_REFERENCE_CHARS:
return text[:_MAX_REFERENCE_CHARS] + "\n\n[... reference truncated ...]\n"
return text
def _interactive_shell_slash_hints(provider: SlashCommandProvider | None = None) -> str:
lines = [
"In the interactive shell, describe an incident or paste alert JSON to run "
+ "a investigation pipeline, or chat with the terminal assistant for CLI help.",
"Alpha mode runs every shell command with no guardrails: plain commands are parsed to "
+ "argv and run without a shell, while pipes, redirects, command substitution, and a "
+ "leading ! all run through a full shell. There is no read-only allowlist or blocked "
+ "command list.",
"Slash commands:",
"",
]
if provider is not None:
for cmd in provider().values():
name = getattr(cmd, "name", "")
description = getattr(cmd, "description", "")
if name:
lines.append(f" {name} - {description}".rstrip())
else:
lines.append(" (slash command registry is supplied by the interactive shell runtime)")
lines.extend(
[
"",
"Non-interactive investigation: `opensre investigate` with stdin, file, or flags.",
"Launch the interactive shell: `opensre` (requires a TTY).",
]
)
return "\n".join(lines)
class CliReference:
"""Session-scoped cache for assembled CLI help reference text.
Holds its cache as instance state so each REPL session (via
:func:`session_cli_reference`) owns an isolated cache with no module-level
mutable globals.
"""
name = "cli"
def __init__(self) -> None:
self._signature: str | None = None
self._text: str | None = None
self._created_at_monotonic: float = 0.0
self._hits: int = 0
self._misses: int = 0
self._slash_commands_provider: SlashCommandProvider | None = None
self._command_group_provider: CommandGroupProvider | None = None
def set_slash_commands_provider(self, provider: SlashCommandProvider | None) -> None:
"""Set the shell-owned slash command source used for grounding text."""
self._slash_commands_provider = provider
self.invalidate()
def set_command_group_provider(self, provider: CommandGroupProvider | None) -> None:
"""Bind a surface-owned callable that returns the root Click command group."""
self._command_group_provider = provider
self.invalidate()
def _resolve_command_group(self) -> click.Command | None:
provider = self._command_group_provider
if provider is None:
return None
try:
return provider()
except Exception:
_logger.debug("Command group provider raised; skipping CLI reference", exc_info=True)
return None
def build_text(self) -> str:
"""Assemble ``opensre`` and subcommand ``--help`` output for LLM grounding.
Cached on this instance while the command registry signature matches.
"""
slash_provider = self._slash_commands_provider
cli_group = self._resolve_command_group()
sig = _current_cli_signature(cli_group, slash_provider)
if self._text is not None and self._signature == sig:
self._hits += 1
return self._text
self._misses += 1
text = _build_cli_reference_text_uncached(cli_group, slash_provider)
if cli_group is not None and _is_cacheable_cli_reference(text):
self._signature = sig
self._text = text
self._created_at_monotonic = time.monotonic()
else:
self._signature = None
self._text = None
self._created_at_monotonic = 0.0
_logger.warning(
"CLI reference build produced non-cacheable output (%d chars); skipping cache",
len(text),
)
return text
def invalidate(self) -> None:
"""Drop cached CLI reference text (for tests or forced refresh)."""
self._signature = None
self._text = None
self._created_at_monotonic = 0.0
self._hits = 0
self._misses = 0
def stats(self) -> CacheStats:
"""Debug counters for grounding cache hit/miss and last signature."""
return CacheStats(
name=self.name,
hits=self._hits,
misses=self._misses,
cached=self._text is not None,
signature=self._signature,
created_at_monotonic=self._created_at_monotonic,
)
def as_grounding_source(self) -> GroundingSource:
return GroundingSource(name=self.name, stats_fn=self.stats)
def _slash_commands() -> Mapping[str, object]:
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS
return SLASH_COMMANDS
def _cli_command_group() -> click.Command | None:
from surfaces.cli.__main__ import cli
return cli
_SESSION_CLI_REFERENCE_ATTR = "_shell_cli_reference"
def session_cli_reference(session: Any) -> CliReference:
"""Return the session-scoped CLI catalog cache, creating it on first use."""
cached = getattr(session, _SESSION_CLI_REFERENCE_ATTR, None)
if isinstance(cached, CliReference):
return cached
cli = CliReference()
cli.set_command_group_provider(_cli_command_group)
cli.set_slash_commands_provider(_slash_commands)
setattr(session, _SESSION_CLI_REFERENCE_ATTR, cli)
return cli
def shell_prompt_context_provider(session: Any) -> ShellPromptContextProvider:
"""Build a prompt provider that reuses the session's CLI catalog cache."""
return ShellPromptContextProvider(session)
class ShellPromptContextProvider:
""":class:`core.agent_harness.ports.PromptContextProvider` for the interactive shell.
Owns CLI catalog assembly (``surfaces.cli`` + slash commands) and delegates
repo-level grounding (AGENTS.md, environment block) to
:class:`~core.agent_harness.prompts.prompt_context.DefaultPromptContextProvider`.
"""
def __init__(self, session: Any) -> None:
self._base = DefaultPromptContextProvider(session)
self._cli = session_cli_reference(session)
def cli_reference(self) -> str:
return self._cli.build_text()
def agents_md(self) -> str:
return self._base.agents_md()
def investigation_flow(self) -> str:
return self._base.investigation_flow()
def environment_block(self) -> str:
return self._base.environment_block()
def suggested_synthetic_prompt(self) -> str:
return self._base.suggested_synthetic_prompt()
def log_diagnostics(self, reason: str) -> None:
self._base.log_diagnostics(reason)
log_grounding_cache_diagnostics([self._cli.as_grounding_source()], reason)
__all__ = [
"CliReference",
"CommandGroupProvider",
"ShellPromptContextProvider",
"SlashCommandProvider",
"session_cli_reference",
"shell_prompt_context_provider",
]
+105
View File
@@ -0,0 +1,105 @@
"""Public REPL entrypoints."""
from __future__ import annotations
import asyncio
import sys
from rich.console import Console
from config.repl_config import ReplConfig
from core.agent_harness.session import SessionManager
from surfaces.interactive_shell.controller import InteractiveShellController
from surfaces.interactive_shell.runtime.context import create_repl_runtime_context
from surfaces.interactive_shell.runtime.startup.first_launch_github import (
require_startup_github_login,
)
from surfaces.interactive_shell.runtime.startup.initial_input import run_initial_input
from surfaces.interactive_shell.ui.banner import render_banner
from surfaces.interactive_shell.ui.input_prompt import build_prompt_session
from tools.system.fleet_monitoring.sweep import run_startup_sweep
_console = Console(
highlight=False, force_terminal=True, color_system="truecolor", legacy_windows=False
)
async def run_repl_async(
initial_input: str | None = None,
_config: ReplConfig | None = None,
resume_session_id: str | None = None,
) -> int:
from platform.analytics.cli import identify_saved_github_username
identify_saved_github_username()
cfg = _config or ReplConfig.load()
pt_session = build_prompt_session()
runtime_context = create_repl_runtime_context(pt_session=pt_session)
session = runtime_context.session
if initial_input:
session.warm_resolved_integrations()
return run_initial_input(initial_input, session)
# Open the session file now that we know this is an interactive REPL run.
SessionManager.for_session(session).open_storage(session)
try:
if resume_session_id:
from surfaces.interactive_shell.command_registry.session_cmds.resume import (
resume_session_by_prefix,
)
slash_command = f"/resume {resume_session_id.strip()}"
if not resume_session_by_prefix(
resume_session_id.strip(),
session,
_console,
slash_command=slash_command,
):
return 1
await InteractiveShellController(
runtime_context,
config=cfg,
console=_console,
).start_interactive_shell()
return 0
finally:
# True end-of-run teardown: persist and release the session's resources.
SessionManager.for_session(session).close(session)
def run_repl(
initial_input: str | None = None,
config: ReplConfig | None = None,
*,
resume_session_id: str | None = None,
) -> int:
cfg = config or ReplConfig.load()
if not cfg.enabled and not resume_session_id:
return 0
if not sys.stdin.isatty() and initial_input is None:
return 0
run_startup_sweep()
try:
if not initial_input:
render_banner(_console)
if not require_startup_github_login(_console):
return 0
return asyncio.run(
run_repl_async(
initial_input=initial_input,
_config=cfg,
resume_session_id=resume_session_id,
)
)
except (EOFError, KeyboardInterrupt):
return 0
__all__ = ["run_repl", "run_repl_async"]
@@ -0,0 +1,31 @@
"""Persistent interactive-shell prompt history + redaction policies."""
from __future__ import annotations
from surfaces.interactive_shell.prompt_history.policy import (
DEFAULT_MAX_ENTRIES,
DEFAULT_REDACTION_RULES,
HistoryPolicy,
RedactingFileHistory,
RedactionRule,
redact_text,
)
from surfaces.interactive_shell.prompt_history.storage import (
clear_persisted_history,
load_command_history_entries,
load_prompt_history,
prompt_history_path,
)
__all__ = [
"DEFAULT_MAX_ENTRIES",
"DEFAULT_REDACTION_RULES",
"HistoryPolicy",
"RedactingFileHistory",
"RedactionRule",
"clear_persisted_history",
"load_command_history_entries",
"load_prompt_history",
"prompt_history_path",
"redact_text",
]
@@ -0,0 +1,219 @@
"""Privacy controls for the interactive-shell command history.
Built-in redaction patterns + a ``RedactingFileHistory`` that subclasses
``prompt_toolkit.FileHistory`` to apply redaction before each entry is
persisted. Settings resolve from env vars and the ``interactive.history``
section of ``~/.opensre/config.yml``; built-in defaults keep
redaction on, persistence on, and entries capped at 5000.
"""
from __future__ import annotations
import os
import re
from collections.abc import Iterator
from pathlib import Path
from typing import Any, ClassVar
from prompt_toolkit.history import FileHistory
from pydantic import ConfigDict
from config.strict_config import StrictConfigModel
DEFAULT_MAX_ENTRIES = 5000
class RedactionRule(StrictConfigModel):
"""One named regex with its replacement."""
model_config = ConfigDict(extra="forbid", frozen=True, arbitrary_types_allowed=True)
name: str
pattern: re.Pattern[str]
replacement: str
def _build_default_rules() -> tuple[RedactionRule, ...]:
raw: list[tuple[str, str, str]] = [
("aws_key", r"(?:AKIA|ASIA)[A-Z0-9]{16}", "[REDACTED:aws_key]"),
(
"aws_secret",
r"(?i)aws_secret_access_key[\s=:]+[A-Za-z0-9/+=]{40}",
"aws_secret_access_key=[REDACTED:aws_secret]",
),
("github_pat_classic", r"ghp_[A-Za-z0-9]{36}", "[REDACTED:github_pat]"),
("github_pat_fine", r"github_pat_[A-Za-z0-9_]{82}", "[REDACTED:github_pat]"),
("anthropic_key", r"sk-ant-[A-Za-z0-9_\-]{40,}", "[REDACTED:anthropic_key]"),
("openai_key", r"sk-(?!ant-)[A-Za-z0-9_\-]{20,}", "[REDACTED:openai_key]"),
("slack_token", r"xox[bopas]-[A-Za-z0-9-]{10,}", "[REDACTED:slack_token]"),
("stripe_key", r"sk_(?:live|test)_[A-Za-z0-9]{24,}", "[REDACTED:stripe_key]"),
("bearer", r"(?i)bearer\s+[A-Za-z0-9_\-\.]{20,}", "Bearer [REDACTED]"),
(
"jwt",
r"eyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}",
"[REDACTED:jwt]",
),
("password_arg", r"(?i)(--password=|password=)\S+", "[REDACTED:password]"),
(
"private_key",
r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
"[REDACTED:private_key]",
),
]
return tuple(
RedactionRule(name=name, pattern=re.compile(p), replacement=repl) for (name, p, repl) in raw
)
DEFAULT_REDACTION_RULES: tuple[RedactionRule, ...] = _build_default_rules()
def redact_text(text: str, rules: tuple[RedactionRule, ...] = DEFAULT_REDACTION_RULES) -> str:
"""Apply each rule's pattern in declared order, replacing every match."""
for rule in rules:
text = rule.pattern.sub(rule.replacement, text)
return text
class HistoryPolicy(StrictConfigModel):
"""Resolved privacy policy for the prompt-history persistence layer."""
model_config = ConfigDict(extra="forbid", frozen=True)
enabled: bool = True
redact: bool = True
max_entries: int = DEFAULT_MAX_ENTRIES
_ENV_ENABLED: ClassVar[str] = "OPENSRE_HISTORY_ENABLED"
_ENV_REDACT: ClassVar[str] = "OPENSRE_HISTORY_REDACT"
_ENV_MAX_ENTRIES: ClassVar[str] = "OPENSRE_HISTORY_MAX_ENTRIES"
@classmethod
def load(cls, file_settings: dict[str, Any] | None = None) -> HistoryPolicy:
"""Resolve from env -> file -> defaults (env wins)."""
file_settings = file_settings or {}
return cls(
enabled=_parse_bool(os.getenv(cls._ENV_ENABLED), file_settings.get("enabled"), True),
redact=_parse_bool(os.getenv(cls._ENV_REDACT), file_settings.get("redact"), True),
max_entries=_parse_int(
os.getenv(cls._ENV_MAX_ENTRIES),
file_settings.get("max_entries"),
DEFAULT_MAX_ENTRIES,
),
)
def _parse_bool(env_val: str | None, file_val: Any, default: bool) -> bool:
if env_val is not None and env_val != "":
return env_val.strip().lower() not in {"0", "false", "off", "no"}
if isinstance(file_val, bool):
return file_val
if isinstance(file_val, str) and file_val.strip() != "":
return file_val.strip().lower() not in {"0", "false", "off", "no"}
if file_val is not None:
return bool(file_val)
return default
def _parse_int(env_val: str | None, file_val: Any, default: int) -> int:
if env_val is not None and env_val.strip():
try:
return max(0, int(env_val.strip()))
except ValueError:
return default
if isinstance(file_val, int) and file_val >= 0:
return file_val
return default
class RedactingFileHistory(FileHistory):
"""``FileHistory`` that redacts known token shapes before persisting.
Also enforces a max-entry retention cap by rewriting the file when
the new total exceeds the cap. ``paused`` lets ``/history off`` stop
persistence at runtime without swapping the History instance.
"""
def __init__(
self,
filename: str,
*,
rules: tuple[RedactionRule, ...] = DEFAULT_REDACTION_RULES,
max_entries: int = DEFAULT_MAX_ENTRIES,
) -> None:
super().__init__(filename)
self.paused: bool = False
self._rules = rules
self._max_entries = max_entries
self._entry_count: int | None = None
def load_history_strings(self) -> Iterator[str]:
"""Yield entries with CRLF artifacts from the on-disk format normalized away."""
for item in super().load_history_strings():
yield item.rstrip("\r\n")
@property
def max_entries(self) -> int:
return self._max_entries
def set_max_entries(self, max_entries: int, *, prune: bool = True) -> None:
self._max_entries = max(0, max_entries)
if prune:
self._prune_to_cap()
def store_string(self, string: str) -> None:
if self.paused:
return
cleaned = redact_text(string, self._rules) if self._rules else string
super().store_string(cleaned)
if self._max_entries <= 0:
return
if self._entry_count is None:
self._entry_count = self._count_entries()
else:
self._entry_count += 1
if self._entry_count > self._max_entries:
self._prune_to_cap()
def _prune_to_cap(self) -> None:
if self._max_entries <= 0:
return
path = Path(os.fsdecode(self.filename))
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return
lines = text.splitlines(keepends=True)
# Each persisted entry begins with a "# <timestamp>" comment line.
boundaries = [i for i, ln in enumerate(lines) if ln.startswith("# ")]
if len(boundaries) <= self._max_entries:
self._entry_count = len(boundaries)
return
keep_from = boundaries[len(boundaries) - self._max_entries]
if keep_from > 0 and lines[keep_from - 1].strip() == "":
keep_from -= 1
try:
path.write_text("".join(lines[keep_from:]), encoding="utf-8")
self._entry_count = self._max_entries
except OSError:
return
def _count_entries(self) -> int:
path = Path(os.fsdecode(self.filename))
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return 0
return sum(1 for line in text.splitlines() if line.startswith("# "))
__all__ = [
"DEFAULT_MAX_ENTRIES",
"DEFAULT_REDACTION_RULES",
"HistoryPolicy",
"RedactingFileHistory",
"RedactionRule",
"redact_text",
]
@@ -0,0 +1,79 @@
"""Persistent command history for the interactive shell prompt."""
from __future__ import annotations
from pathlib import Path
from prompt_toolkit.history import FileHistory, History, InMemoryHistory
from surfaces.interactive_shell.prompt_history.policy import (
HistoryPolicy,
RedactingFileHistory,
)
_HISTORY_FILENAME = "interactive_history"
def prompt_history_path() -> Path:
from config.constants import OPENSRE_HOME_DIR
return OPENSRE_HOME_DIR / _HISTORY_FILENAME
def load_prompt_history(policy: HistoryPolicy | None = None) -> History:
"""Use persistent prompt history when possible, with an in-memory fallback.
When ``policy.enabled`` is False, persistence is skipped entirely and
an in-memory ring is returned. When ``policy.redact`` is False, raw
``FileHistory`` is used so power users can opt out of redaction.
"""
settings = policy or _load_policy()
if not settings.enabled:
return InMemoryHistory()
try:
path = prompt_history_path()
path.parent.mkdir(parents=True, exist_ok=True)
if settings.redact:
return RedactingFileHistory(str(path), max_entries=settings.max_entries)
return FileHistory(str(path))
except OSError:
return InMemoryHistory()
def load_command_history_entries() -> list[str]:
"""Return persisted prompt entries in chronological order."""
try:
path = prompt_history_path()
path.parent.mkdir(parents=True, exist_ok=True)
history = FileHistory(str(path))
raw = list(reversed(list(history.load_history_strings())))
return [line.rstrip("\r\n") for line in raw]
except OSError:
return []
def clear_persisted_history() -> bool:
"""Truncate the on-disk history file. Returns True if the file is gone or empty."""
path = prompt_history_path()
try:
if path.exists():
path.write_text("", encoding="utf-8")
return True
except OSError:
return False
def _load_policy() -> HistoryPolicy:
from config.repl_config import read_history_settings
return HistoryPolicy.load(read_history_settings())
__all__ = [
"clear_persisted_history",
"load_command_history_entries",
"load_prompt_history",
"prompt_history_path",
]
@@ -0,0 +1,252 @@
# Runtime package rules
## Human summary
The `runtime` package holds the focused support modules for the interactive
shell runtime. The top-level bootstrap and controller live one level up in
`interactive_shell/`.
In simple terms:
- `../main.py` starts the interactive session and handles process/bootstrap gates.
- `startup/first_launch_github.py` owns the first-launch GitHub sign-in gate.
- `../controller.py` owns the `InteractiveShellController` orchestration class,
including prompt input, submitted prompt handling, queued turn consumption,
prompt-mediated confirmation waits, one-turn pipeline handoff, background output draining, and
shutdown.
- `core/prompt_manager.py` owns prompt-toolkit setup and prompt rendering.
- `input/` owns prompt input event conversion: EOF, Ctrl-C, CPR cleanup, and
resume hints.
- `utils/input_policy.py` owns prompt stdin/spinner decisions for turns.
- `startup/initial_input.py` owns non-interactive initial-input replay.
- `background/workers.py` owns alert watching, spinner ticking, sampler startup,
and turn-start background output drains.
- `background/` also owns background investigation records, launchers, and
completion notification delivery.
- `core/` holds the core runtime engine:
- `state.py` — shared runtime state (`ReplState`, `SpinnerState`)
- `turn_detection.py` — pure text classifiers for cancel, confirm, and correction detection
- `core.agent_harness.session.tasks` owns the cross-session task registry surfaced via
`/tasks` and `/cancel`.
- Reusable per-agent session state (`Session`) lives in
`core.agent_harness.session`. Terminal runtime context assembly
(`ReplRuntimeContext`, `create_repl_runtime_context`) lives in
`interactive_shell.runtime.context`.
These instructions apply to `interactive_shell/runtime/` and all
subdirectories. Parent `AGENTS.md` files still apply.
## Architectural intent (locked)
The runtime package is intentionally split into focused concerns:
- `core/state.py` — runtime state and transition helpers only.
- `core/turn_detection.py` — pure prompt text classification only.
- `utils/input_policy.py` — terminal stdin/spinner gating decisions only.
- `agent_presentation.py` — terminal presentation for the agent prompt only (agent
lifecycle events, presentation-state reducer/renderer, `ConsoleAgentEventSink`).
- `turn_host.py` — terminal/runtime host for `ShellAgent` prompts only.
- `../controller.py` — stable async entrypoint and async prompt runtime/event loop
orchestration, submitted prompt handling, queued-turn consumption,
prompt-mediated confirmation waits, turn telemetry, one-turn pipeline
handoff, background output draining, and shutdown only.
- `core/prompt_manager.py` — prompt-toolkit setup and prompt rendering only.
- `input/` — prompt input event conversion and terminal-input cleanup only.
- `background/workers.py` — background worker startup and turn-start drain hooks
only.
- `core.agent_harness.session.background` — background investigation record and
preferences only.
- `background/runner.py` — session-local background investigation launchers only.
- `background/notifications.py` — background RCA completion notification delivery only.
- `../main.py` — process/bootstrap boundary only.
- `startup/initial_input.py` — scripted initial-input replay only.
- `startup/first_launch_github.py` — first-launch GitHub sign-in gate only.
- `core.agent_harness.session.tasks` — task registry + persistence only.
- `core.agent_harness.accounting.token_accounting` — session-scoped LLM token accounting and run metadata only.
Keep these boundaries strict. If a change crosses concerns, move code to the
owner module instead of broadening module responsibilities.
## Data flow contract (locked)
The interactive runtime must keep this shape:
1. `interactive_shell.main.run_repl` (synchronous entrypoint) sets up
process-level concerns and calls `run_repl_async`.
2. `interactive_shell.main.run_repl_async` (async body) creates `InteractiveShellController`.
3. `InteractiveShellController.start_interactive_shell` owns prompt lifecycle,
submitted input handling, queued-turn consumption, and per-turn task
scheduling.
4. `runtime.turn_host.run_agent_turn_queue` consumes queued prompts through an
injected `run_turn` callable, which in production is
`lambda text: run_agent_turn(self.turn_runtime, text)`.
5. `runtime.turn_host.run_agent_turn(runtime, text)` drives one submitted turn.
`AgentTurnRuntime` is the immutable dependency bundle it operates on
(`session`, `state`, `spinner`, `invalidate_prompt`, `request_exit`);
`run_agent_turn` owns presentation setup, prompt-mediated confirmation,
dispatch state, and per-turn execution.
6. `interactive_shell.runtime.shell_turn_execution.execute_shell_turn` binds shell adapters
around `core.agent_harness.turns.orchestrator.run_turn`.
7. `core.agent_harness` owns one prompt's action/answer mechanics and accounting
finalization. The terminal presentation for `AgentEvent` emissions lives in
`runtime/agent_presentation.py`.
Do not invert this dependency direction.
### Architecture diagram
```mermaid
flowchart TD
runRepl["interactive_shell.main.run_repl"] --> replMain["interactive_shell.main.run_repl_async"]
replMain --> controller["interactive_shell.controller.InteractiveShellController"]
controller --> turnHost["runtime.turn_host.run_agent_turn(turn_runtime, text)"]
turnHost --> turnEntry["interactive_shell.runtime.shell_turn_execution.execute_shell_turn"]
turnEntry --> coreHarness["core.agent_harness.turns.orchestrator.run_turn"]
coreHarness --> sideEffects["slash/help/agent/follow-up/investigation side effects"]
controller --> replState["core.state.ReplState"]
controller --> spinnerState["core.state.SpinnerState"]
controller --> inputReader["input.PromptInputReader"]
```
## State ownership rules
- `ReplState` is the single source of truth for:
- active dispatch task
- cancellation event
- confirmation event/response lifecycle
- exit requests
- the explicit turn `phase` (`TurnPhase`: `IDLE`, `DISPATCHING`,
`AWAITING_CONFIRMATION`, `CANCELLING`)
- Mutate turn state through the `ReplState` transition methods, never by poking
raw fields from other modules:
- `start_dispatch` / `attach_turn_task` / `attach_cancel_event` -> `DISPATCHING`
- `begin_confirmation` -> `AWAITING_CONFIRMATION`; `clear_confirmation` returns
to `DISPATCHING`/`IDLE` (and never clobbers an in-progress `CANCELLING`)
- `cancel_current_dispatch` -> `CANCELLING` (only when there is something to
cancel) then signals the cancel/confirm events and `task.cancel`
- `finish_dispatch` / `clear_current_task` -> `IDLE`
- `phase` is authoritative for confirmation and cancelling. `is_dispatch_running()`
stays derived from the asyncio task (the runtime truth of the in-flight turn);
`is_awaiting_confirmation()` and `is_cancelling()` are derived from `phase`.
- Do not reorder the signaling inside `cancel_current_dispatch` or move the
`confirm_response` reset after the `confirm_event` publish in
`begin_confirmation`; both orderings are load-bearing for cancellation and
confirmation race-safety.
- `SpinnerState` owns spinner rendering state only; it must not depend on
runtime task management.
## Turn execution rules
- Do not reintroduce `dispatch.py`, an `AgentTurnRunner`-style wrapper class, or
any compatibility-only forwarding module.
- The terminal host lives in `runtime/turn_host.py`: `run_agent_turn(runtime,
text)` owns shell presentation (StreamingConsole, spinner, recorder, progress
scope), constructs a `ConsoleAgentEventSink`, owns dispatch state, and
lazily imports + calls
`interactive_shell.runtime.shell_turn_execution.execute_shell_turn`.
`AgentTurnRuntime` is the immutable dependency bundle it operates on; the
controller constructs it and passes a bound `run_agent_turn` coroutine into
`run_agent_turn_queue`.
- The shell adapter entry lives in `runtime/shell_turn_execution.py`: `execute_shell_turn`
composes the action-turn (`runtime/action_turn.py`), gather (`runtime/integration_tool_gathering.py`),
and answer (`runtime/answer_turn.py`) adapters plus accounting around
`core.agent_harness.turns.orchestrator.run_turn`. Each adapter owns its own
binding; tests import them from their owning module (not `shell_turn_execution`).
- The reusable per-prompt loop lives in `core.agent_harness`: turn snapshots,
observation reset, action/response routing, and core result construction stay
surface-agnostic.
- Keep terminal side effects (spinner, prompt suppression, `console.print`, CPR
drain) in `ConsoleAgentEventSink` — defined in `runtime/agent_presentation.py`
— not in the turn-entry adapter or the core harness.
- Put cancel/confirm/correction text classifiers in `core/turn_detection.py`.
- Put stdin blocking and spinner decisions in `utils/input_policy.py`.
- Keep prompt-mediated confirmation waiting in `runtime/core/confirmation.py`.
- Turn accounting is consolidated behind `ShellTurnAccounting` in
`interactive_shell/runtime/core/turn_accounting.py`, invoked from
`interactive_shell.runtime.shell_turn_execution.execute_shell_turn`. It owns action-agent analytics, terminal-turn aggregate
telemetry, prompt-recorder flush, conversational-turn persistence, and the final
assistant-intent stamp. `interactive_shell.runtime.action_turn.run_action_tool_turn` returns facts
only (`ToolCallingTurnResult` with `accounting_status` of `completed` / `not_run`)
and emits no analytics itself. Do not re-scatter accounting back into
`run_action_tool_turn` or standalone `_record_*` helpers.
## Controller rules
- `../controller.py` owns:
- `InteractiveShellController`
- `start_interactive_shell` shell lifecycle orchestration
- alert listener setup/teardown
- `AgentTurnRuntime` construction and shutdown
- queued prompt submission
- `turn_host.py` owns:
- `run_input_loop` (module-level) — read and handle user input until exit
- `run_agent_turn_queue` (module-level) — consume queued prompts until exit (runs an injected `run_turn`)
- `run_agent_turn(runtime, text)` (module-level) — presentation, dispatch state, prompt-mediated confirmation, and turn-entry invocation over an `AgentTurnRuntime`
- `ConsoleAgentEventSink` (in `runtime/agent_presentation.py`) — terminal presentation for agent lifecycle events over `_reduce_agent_presentation` / `_render_agent_presentation_transition`
- queued turn consumption
- per-turn task lifecycle
- dispatch start/finish state transitions
- prompt-mediated confirmation waiting
- turn telemetry and `execute_shell_turn` invocation
- current turn cancellation helpers
- coordination between prompt, background, and shutdown helpers
- `core/prompt_manager.py` owns:
- prompt-toolkit wiring
- prompt rendering callbacks
- pending prompt defaults and autosubmit handling
- `input/` owns:
- prompt input event types
- terminal EOF and Ctrl-C conversion
- CPR cleanup for submitted prompt text
- session resume hints when prompt input closes
- `background/workers.py` owns:
- alert watcher lifecycle
- spinner ticker lifecycle
- sampler startup
- background notice drains at turn start
- Keep prompt rendering concerns in runtime/prompting modules, not in
dispatch/execution.
## Main/bootstrap rules
- `../main.py` owns:
- startup sweep
- TTY/non-TTY gate
- banner display for interactive runs
- async boundary (`asyncio.run`)
- `../controller.py` owns alert listener setup/teardown because the inbox is
part of the running shell lifecycle.
- Do not move per-turn dispatch/runtime logic back into startup bootstrap.
## Compatibility surface policy
- `runtime/__init__.py` should be a thin export layer.
- Do not duplicate business logic in `__init__.py`.
- `runtime/__init__.py` exports `Session` from `core.agent_harness.session` and
runtime-context helpers from `interactive_shell.runtime.context`. New shared
runtime code should import session names directly from `core.agent_harness.session`.
- Do not re-add `_xxx` underscore aliases or wrapper functions for
compatibility. Tests and callers should import canonical names from their
owning submodule.
## Test seam policy
- Prefer patching canonical module seams:
- `interactive_shell.controller.*` for prompt-loop, queued-turn, confirmation behavior,
one-turn pipeline execution, and side effects
- `interactive_shell.main.*` for process/bootstrap behavior
- `runtime.core.state.*` for state-specific behavior
- Avoid adding new tests that monkeypatch package-root internals in
`runtime.__init__` unless there is no stable canonical seam.
## Refactor guardrails
- No behavior changes to action-planning policy should be introduced from
`runtime/` refactors.
- Keep interruption semantics unchanged:
- Esc or bare cancel commands interrupt active dispatch
- cancellation moves the turn to `TurnPhase.CANCELLING` and signals both the
cancel event and any pending confirmation event before `task.cancel`
- confirmation prompts are cancel-safe and never silently auto-confirm; a
cancel during confirmation must not be downgraded back to `DISPATCHING`
- Preserve observability semantics (turn telemetry and turn summaries).
@@ -0,0 +1,29 @@
from __future__ import annotations
from platform.common.task_registry import TaskRegistry
from platform.common.task_types import TaskKind, TaskRecord, TaskStatus
from surfaces.interactive_shell.runtime.context import (
ReplRuntimeContext,
SessionBootstrapSpec,
create_repl_runtime_context,
prepare_repl_session,
)
from surfaces.interactive_shell.session.background_investigations import (
BackgroundInvestigationRecord,
BackgroundNotificationPreferences,
)
from surfaces.interactive_shell.session.session import Session
__all__ = [
"BackgroundInvestigationRecord",
"BackgroundNotificationPreferences",
"ReplRuntimeContext",
"Session",
"SessionBootstrapSpec",
"TaskKind",
"TaskRecord",
"TaskRegistry",
"TaskStatus",
"create_repl_runtime_context",
"prepare_repl_session",
]
@@ -0,0 +1,113 @@
"""Shell adapter for one action-selection turn.
Binds the interactive shell's console, session, and default providers around
core ``run_action_agent_turn``.
"""
from __future__ import annotations
from collections.abc import Callable
from rich.console import Console
from core.agent_harness.error_reporting import DefaultErrorReporter
from core.agent_harness.llm_resolution import default_llm_factory
from core.agent_harness.ports import OutputSink
from core.agent_harness.tools.tool_provider import DefaultToolProvider
from core.agent_harness.turns.action_driver import ToolCallingDeps, run_action_agent_turn
from core.agent_harness.turns.turn_plan import TurnPlan
from core.agent_harness.turns.turn_results import ToolCallingTurnResult
from core.execution import ToolExecutionHooks
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS
from surfaces.interactive_shell.command_registry.suggestions import resolve_literal_slash_typo
from surfaces.interactive_shell.runtime.agent_harness_adapters import resolve_output_sink
from surfaces.interactive_shell.runtime.subprocess_runner.repl_presenter import (
ReplSubprocessPresenter,
)
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui.action_rendering import ActionRenderObserver
def _complete_literal_slash_typo_turn(
message: str,
session: Session,
output: OutputSink,
) -> ToolCallingTurnResult | None:
"""Handle unknown slash roots and invalid subcommands before tool validation."""
typo = resolve_literal_slash_typo(message, SLASH_COMMANDS)
if typo is None:
return None
output.print()
output.print(typo.message)
session.record(
"slash",
message.strip(),
ok=False,
response_text=typo.message,
slash_outcome=typo.outcome,
)
return ToolCallingTurnResult(0, 1, 0, False, True, response_text=typo.message)
def _subprocess_presenter_factory(
session: Session,
console: Console,
confirm_fn: Callable[[str], str] | None,
is_tty: bool | None,
action_already_listed: bool,
) -> ReplSubprocessPresenter:
return ReplSubprocessPresenter(
session,
console,
confirm_fn=confirm_fn,
is_tty=is_tty,
action_already_listed=action_already_listed,
)
def run_action_tool_turn(
message: str,
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
request_exit: Callable[[], None] | None = None,
deps: ToolCallingDeps | None = None,
turn_plan: TurnPlan | None = None,
output: OutputSink | None = None,
tool_hooks: ToolExecutionHooks | None = None,
) -> ToolCallingTurnResult:
"""Run one action-selection turn through core with shell adapters bound."""
resolved_output = resolve_output_sink(console, output)
typo_result = _complete_literal_slash_typo_turn(message, session, resolved_output)
if typo_result is not None:
return typo_result
effective_deps = (
deps
if deps is not None and deps.llm_factory is not None
else ToolCallingDeps(llm_factory=default_llm_factory)
)
return run_action_agent_turn(
message,
session,
output=resolved_output,
tools=DefaultToolProvider(
session,
console,
request_exit=request_exit,
observer_factory=lambda msg: ActionRenderObserver(
session=session, console=console, message=msg
),
subprocess_presenter_factory=_subprocess_presenter_factory,
),
confirm_fn=confirm_fn,
is_tty=is_tty,
deps=effective_deps,
turn_plan=turn_plan,
error_reporter=DefaultErrorReporter(),
tool_hooks=tool_hooks,
)
__all__ = ["run_action_tool_turn"]
@@ -0,0 +1,66 @@
"""Interactive-shell output adapter implementing :mod:`core.agent_harness.ports`.
This module owns terminal rendering only. Shared action-tool, reasoning-client,
run-record, and error-reporting providers live in :mod:`core.agent_harness`.
"""
from __future__ import annotations
from collections.abc import Iterable
from rich.console import Console
from rich.markup import escape
from core.agent_harness.ports import OutputSink
from core.llm.shared.llm_retry import CREDIT_EXHAUSTED_MARKER
from surfaces.interactive_shell.ui import (
stream_to_console,
)
from surfaces.interactive_shell.ui.streaming import render_response_header
class ShellOutputSink:
""":class:`core.agent_harness.ports.OutputSink` over a Rich console."""
def __init__(self, console: Console) -> None:
self._console = console
def print(self, message: str = "") -> None:
self._console.print(message)
def render_response_header(self, label: str) -> None:
render_response_header(self._console, label)
def render_error(self, message: str) -> None:
self._console.print(f"[yellow]{escape(message)}[/]")
# On a credit/billing wall, add the in-tool recovery hint.
if CREDIT_EXHAUSTED_MARKER in message:
self._console.print("[dim]Run /model to switch to another provider.[/]")
self._console.print(
"[dim]Or run /auth login <provider> to re-authenticate "
"or add a different provider.[/]"
)
def stream(
self,
*,
label: str,
chunks: Iterable[str],
suppress_if_starts_with: str | None = None,
) -> str:
return stream_to_console(
self._console,
label=label,
chunks=iter(chunks),
suppress_if_starts_with=suppress_if_starts_with,
)
def resolve_output_sink(console: Console, output: OutputSink | None) -> OutputSink:
"""Return the caller's sink, or a shell sink bound to ``console``."""
if output is not None:
return output
return ShellOutputSink(console)
__all__ = ["ShellOutputSink", "resolve_output_sink"]
@@ -0,0 +1,151 @@
"""Terminal presentation for the interactive shell agent prompt.
This module owns the **UI / presentation** side of one submitted shell prompt:
the pure presentation-state reducer, the effectful terminal transition renderer,
and the ``ConsoleAgentEventSink`` imperative shell that wires them together.
Keeping this separate from ``runtime/shell_turn_execution.py`` isolates spinner
lifecycle, prompt suppression, interruption/error messages, and stale CPR
draining from the turn's action-routing and prompt-construction logic.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Literal
from rich.markup import escape
from surfaces.interactive_shell.runtime.core.state import SpinnerState
from surfaces.interactive_shell.runtime.utils.input_policy import turn_should_show_spinner
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui import (
DIM,
ERROR,
WARNING,
)
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
from surfaces.interactive_shell.ui.streaming.console import StreamingConsole
@dataclass(frozen=True)
class AgentEvent:
"""Agent lifecycle event emitted during one submitted shell turn."""
type: Literal["turn_start", "turn_interrupted", "turn_error", "turn_end"]
text: str | None = None
error: Exception | None = None
AgentEventSink = Callable[[AgentEvent], Awaitable[None]]
@dataclass(frozen=True)
class AgentPresentationState:
"""Immutable presentation state evolved across lifecycle events."""
show_spinner: bool = False
prompt_suppressed: bool = False
def _reduce_agent_presentation(
state: AgentPresentationState,
event: AgentEvent,
*,
should_show_spinner: bool,
) -> AgentPresentationState:
"""Compute the next presentation state for *event* (pure)."""
if event.type == "turn_start":
return AgentPresentationState(
show_spinner=should_show_spinner,
prompt_suppressed=should_show_spinner,
)
if event.type == "turn_end":
return AgentPresentationState()
if event.type in {"turn_interrupted", "turn_error"}:
return state
raise ValueError(f"Unknown agent event type: {event.type!r}")
async def _render_agent_presentation_transition(
*,
previous: AgentPresentationState,
current: AgentPresentationState,
event: AgentEvent,
console: StreamingConsole,
spinner: SpinnerState,
) -> None:
"""Perform the terminal side effects for one presentation transition."""
match event.type:
case "turn_start":
if current.show_spinner:
spinner.start()
case "turn_interrupted":
console.print(f"[{WARNING}]· interrupted[/]")
case "turn_error":
exc = event.error
if exc is None:
raise ValueError("turn_error event requires an error")
console.print(f"[{ERROR}]turn error:[/] {escape(str(exc))}")
# On a credit/billing wall, add the in-tool recovery hint.
from core.llm.shared.llm_retry import LLMCreditExhaustedError
if isinstance(exc, LLMCreditExhaustedError):
console.print(f"[{DIM}]Run /model to switch to another provider.[/]")
console.print(
f"[{DIM}]Or run /auth login <provider> to re-authenticate "
f"or add a different provider.[/]"
)
case "turn_end":
if previous.show_spinner:
spinner.stop()
await asyncio.sleep(0.05)
drain_stale_cpr_bytes()
case _:
raise ValueError(f"Unknown agent event type: {event.type!r}")
class ConsoleAgentEventSink:
"""Render agent lifecycle events to the terminal console.
Imperative shell: it holds the evolving ``AgentPresentationState`` and routes
each event through the pure ``_reduce_agent_presentation`` reducer and the
effectful ``_render_agent_presentation_transition`` renderer.
"""
def __init__(
self,
*,
session: Session,
spinner: SpinnerState,
console: StreamingConsole,
) -> None:
self.session = session
self.spinner = spinner
self.console = console
self.state = AgentPresentationState()
async def __call__(self, event: AgentEvent) -> None:
previous = self.state
self.state = _reduce_agent_presentation(
previous,
event,
should_show_spinner=turn_should_show_spinner(event.text or "", self.session),
)
await _render_agent_presentation_transition(
previous=previous,
current=self.state,
event=event,
console=self.console,
spinner=self.spinner,
)
__all__ = [
"AgentEvent",
"AgentPresentationState",
"ConsoleAgentEventSink",
"AgentEventSink",
]
@@ -0,0 +1,63 @@
"""Shell adapter for one conversational answer turn.
Binds the interactive shell's Rich output, grounding caches, reasoning client,
and telemetry around core ``stream_answer``.
"""
from __future__ import annotations
from collections.abc import Callable
from rich.console import Console
from core.agent_harness.accounting.run_record import DefaultRunRecordFactory
from core.agent_harness.error_reporting import DefaultErrorReporter
from core.agent_harness.ports import OutputSink
from core.agent_harness.turns.default_reasoning_client import DefaultReasoningClientProvider
from core.agent_harness.turns.orchestrator import (
stream_answer as core_stream_answer,
)
from core.agent_harness.turns.turn_plan import TurnPlan
from surfaces.interactive_shell.grounding.cli_reference import shell_prompt_context_provider
from surfaces.interactive_shell.runtime.agent_harness_adapters import resolve_output_sink
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.utils.telemetry import LlmRunInfo
def answer_shell_question(
message: str,
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
tool_observation: str | None = None,
tool_observation_on_screen: bool = True,
handoff_contents: tuple[str, ...] = (),
turn_plan: TurnPlan | None = None,
output: OutputSink | None = None,
) -> LlmRunInfo | None:
"""Answer one shell question through the grounded conversational assistant."""
resolved_output = resolve_output_sink(console, output)
return core_stream_answer(
message,
session,
resolved_output,
prompts=shell_prompt_context_provider(session),
reasoning=DefaultReasoningClientProvider(
output=resolved_output,
error_reporter=DefaultErrorReporter(),
session=session,
),
run_factory=DefaultRunRecordFactory(session),
error_reporter=DefaultErrorReporter(),
confirm_fn=confirm_fn,
is_tty=is_tty,
tool_observation=tool_observation,
tool_observation_on_screen=tool_observation_on_screen,
handoff_contents=handoff_contents,
turn_plan=turn_plan,
)
__all__ = ["answer_shell_question"]
@@ -0,0 +1 @@
"""Background runtime support for interactive shell."""
@@ -0,0 +1,47 @@
"""Background RCA notification helpers."""
from __future__ import annotations
from surfaces.interactive_shell.session.background_investigations import (
BackgroundInvestigationRecord,
)
def deliver_background_notifications(
*,
record: BackgroundInvestigationRecord,
channels: tuple[str, ...],
) -> dict[str, str]:
"""Send configured notifications for a completed background RCA."""
# Imported lazily: email delivery only fires on background-RCA completion, so
# the SMTP client must not load into the base REPL boot import path.
from integrations.smtp.delivery import format_background_rca_email, send_smtp_report
results: dict[str, str] = {}
from integrations.catalog import resolve_effective_integrations
effective_integrations = resolve_effective_integrations()
for channel in channels:
if channel != "email":
results[channel] = "unsupported"
continue
smtp_integration = effective_integrations.get("smtp")
smtp_config = smtp_integration.get("config") if isinstance(smtp_integration, dict) else None
if not isinstance(smtp_config, dict):
results["email"] = "missing smtp integration"
continue
subject, body = format_background_rca_email(
task_id=record.task_id,
command=record.command,
root_cause=record.root_cause,
top_analysis=record.top_analysis,
next_steps=record.next_steps,
stats=record.stats,
)
ok, error = send_smtp_report(report=body, subject=subject, smtp_ctx=smtp_config)
results["email"] = "sent" if ok else f"failed: {error}"
return results
@@ -0,0 +1,236 @@
"""Helpers for launching session-local background investigations."""
from __future__ import annotations
import threading
from collections.abc import Callable
from contextlib import nullcontext
from typing import Any
from uuid import uuid4
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console
from rich.markup import escape
from platform.analytics.cli import track_investigation
from platform.analytics.source import EntrypointSource, TriggerMode
from platform.common.errors import OpenSREError
from surfaces.interactive_shell.runtime import (
BackgroundInvestigationRecord,
Session,
TaskKind,
)
from surfaces.interactive_shell.runtime.background.notifications import (
deliver_background_notifications,
)
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
BackgroundRunFn = Callable[..., dict[str, Any]]
def _safe_console_print(console: Console, message: str) -> None:
isatty = getattr(console.file, "isatty", None)
stdout_context = patch_stdout(raw=True) if callable(isatty) and isatty() else nullcontext()
with stdout_context:
console.print(message)
def drain_background_notices(session: Session, console: Console) -> None:
"""Print queued background investigation status lines on the main REPL thread."""
for message in session.terminal.drain_background_notices():
_safe_console_print(console, message)
def _build_record(
*,
task_id: str,
command: str,
investigation_id: str,
) -> BackgroundInvestigationRecord:
return BackgroundInvestigationRecord(
task_id=task_id,
status="running",
command=command,
investigation_id=investigation_id,
)
def _top_analysis(final_state: dict[str, Any]) -> tuple[str, ...]:
claims = final_state.get("validated_claims", [])
if not isinstance(claims, list):
return ()
lines: list[str] = []
for entry in claims:
if not isinstance(entry, dict):
continue
claim = str(entry.get("claim") or "").strip()
if claim:
lines.append(claim)
if len(lines) >= 3:
break
return tuple(lines)
def _next_steps(final_state: dict[str, Any]) -> tuple[str, ...]:
steps = final_state.get("remediation_steps", [])
if not isinstance(steps, list):
return ()
values: list[str] = []
for step in steps[:3]:
text = str(step).strip()
if text:
values.append(text)
return tuple(values)
def _stats(final_state: dict[str, Any]) -> dict[str, Any]:
tool_calls = final_state.get("evidence_entries", [])
loops = final_state.get("investigation_loop_count", 0)
validity = final_state.get("validity_score", 0.0)
return {
"tool_call_count": len(tool_calls) if isinstance(tool_calls, list) else 0,
"investigation_loop_count": int(loops) if isinstance(loops, int | float) else 0,
"validity_score": float(validity) if isinstance(validity, int | float) else 0.0,
}
def _start_background_investigation(
*,
session: Session,
console: Console,
display_command: str,
run_fn: BackgroundRunFn,
kwargs: dict[str, Any],
investigation_target: str = "",
input_path: str | None = None,
) -> str:
investigation_id = str(uuid4())
session.last_investigation_id = investigation_id
task = session.task_registry.create(TaskKind.INVESTIGATION, command=display_command)
task.mark_running()
record = _build_record(
task_id=task.task_id,
command=display_command,
investigation_id=investigation_id,
)
session.terminal.background_investigations[task.task_id] = record
def _worker() -> None:
try:
with track_investigation(
entrypoint=EntrypointSource.CLI_REPL_FILE,
trigger_mode=TriggerMode.FILE,
input_path=input_path,
interactive=True,
investigation_id=investigation_id,
investigation_target=investigation_target or None,
session=session,
) as tracker:
final_state = run_fn(cancel_requested=task.cancel_requested, **kwargs)
tracker.record_loop_metrics_from_state(final_state)
root = str(final_state.get("root_cause") or "")
record.status = "completed"
record.root_cause = root
record.top_analysis = _top_analysis(final_state)
record.next_steps = _next_steps(final_state)
record.stats = _stats(final_state)
record.final_state = dict(final_state)
record.notification_results = deliver_background_notifications(
record=record,
channels=session.terminal.background_notification_preferences.channels,
)
task.mark_completed(result=root)
session.terminal.enqueue_background_notice(
f"[{HIGHLIGHT}]background investigation complete[/] "
f"[{DIM}]— task {escape(task.task_id)} ready; "
f"use[/] [{HIGHLIGHT}]/background show {escape(task.task_id)}[/]",
)
except KeyboardInterrupt:
record.status = "cancelled"
task.mark_cancelled()
session.terminal.enqueue_background_notice(
f"[{WARNING}]background investigation cancelled[/] "
f"[{DIM}]for task {escape(task.task_id)}.[/]",
)
except OpenSREError as exc:
record.status = "failed"
task.mark_failed(str(exc))
session.terminal.enqueue_background_notice(
f"[{ERROR}]background investigation failed[/] "
f"[{DIM}]for task {escape(task.task_id)}:[/] {escape(str(exc))}",
)
except Exception as exc: # noqa: BLE001
record.status = "failed"
task.mark_failed(str(exc))
report_exception(exc, context="surfaces.interactive_shell.background_investigation")
session.terminal.enqueue_background_notice(
f"[{ERROR}]background investigation failed[/] "
f"[{DIM}]for task {escape(task.task_id)}:[/] {escape(str(exc))}",
)
thread = threading.Thread(
target=_worker,
daemon=True,
name=f"background-investigation-{task.task_id}",
)
thread.start()
_safe_console_print(
console,
f"[{DIM}]background investigation started — task[/] [bold]{escape(task.task_id)}[/bold]. "
f"[{HIGHLIGHT}]/background list[/] [{DIM}]to monitor, "
f"[/][{HIGHLIGHT}]/cancel {escape(task.task_id)}[/] [{DIM}]to stop.[/]",
)
return task.task_id
def start_background_text_investigation(
*,
alert_text: str,
session: Session,
console: Console,
display_command: str = "background free-text investigation",
investigation_target: str = "",
) -> str:
from surfaces.interactive_shell.runtime.investigation_adapter import (
run_investigation_for_session_background,
)
return _start_background_investigation(
session=session,
console=console,
display_command=display_command,
run_fn=run_investigation_for_session_background,
kwargs={
"alert_text": alert_text,
"context_overrides": session.accumulated_context or None,
},
investigation_target=investigation_target,
input_path=display_command,
)
def start_background_template_investigation(
*,
template_name: str,
session: Session,
console: Console,
display_command: str,
investigation_target: str = "",
) -> str:
from surfaces.interactive_shell.runtime.investigation_adapter import (
run_sample_alert_for_session_background,
)
return _start_background_investigation(
session=session,
console=console,
display_command=display_command,
run_fn=run_sample_alert_for_session_background,
kwargs={
"template_name": template_name,
"context_overrides": session.accumulated_context or None,
},
investigation_target=investigation_target,
input_path=f"template:{template_name}",
)
@@ -0,0 +1,127 @@
"""Background task lifecycle for the interactive REPL runtime."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable, Coroutine
from typing import Any
from rich.console import Console
from core.domain.alerts import inbox as _alert_inbox
from surfaces.interactive_shell.runtime.background.runner import drain_background_notices
from surfaces.interactive_shell.runtime.core.state import ReplState, SpinnerState
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui.alerts import drain_and_render_incoming
log = logging.getLogger(__name__)
class BackgroundTaskManager:
"""Start background workers and drain their user-visible output."""
def __init__(
self,
session: Session,
state: ReplState,
spinner: SpinnerState,
inbox: _alert_inbox.AlertInbox | None,
prompt_invalidator: Callable[[], None],
) -> None:
self.session = session
self.state = state
self.spinner = spinner
self.inbox = inbox
self.prompt_invalidator = prompt_invalidator
self.tasks: list[tuple[str, asyncio.Task[None]]] = []
self._loop: asyncio.AbstractEventLoop | None = None
self._sampler_started = False
def start_all(
self,
processor_coro: Callable[[], Coroutine[Any, Any, None]],
) -> list[tuple[str, asyncio.Task[None]]]:
# The fleet sampler (and its psutil dependency) is intentionally NOT
# started here: local-agent monitoring only runs once the user actually
# opens /fleet, via ``ensure_fleet_sampler_started``.
self._loop = asyncio.get_running_loop()
self.tasks = [
("processor", asyncio.create_task(processor_coro())),
("alert watcher", asyncio.create_task(self._alert_watcher())),
("spinner ticker", asyncio.create_task(self._spinner_ticker())),
]
return self.tasks
def ensure_fleet_sampler_started(self) -> None:
"""Start the fleet sampler on demand (first live ``/fleet`` use).
Safe to call from any thread: a shell turn (and thus the ``/fleet``
handler) runs in a worker thread via ``asyncio.to_thread``, so sampler
task creation is marshalled back onto the REPL event loop. Idempotent.
"""
loop = self._loop
if loop is None:
return
loop.call_soon_threadsafe(self._start_sampler_task)
def _start_sampler_task(self) -> None:
if self._sampler_started:
return
# Imported lazily so base REPL startup does not pull the sampler +
# psutil into the import path.
from tools.system.fleet_monitoring.sampler import start_sampler
self._sampler_started = True
self.tasks.append(("sampler", start_sampler()))
def drain_turn_start_output(self, console: Console) -> None:
if self.inbox is not None:
try:
drain_and_render_incoming(self.session, console, self.inbox)
except Exception as exc:
log.warning("Error draining alerts at turn start: %s", exc)
try:
drain_background_notices(self.session, console)
except Exception as exc:
log.warning("Error draining background notices at turn start: %s", exc)
async def _alert_watcher(self) -> None:
if self.inbox is None:
return
alert_console = Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
drain_and_render_incoming(self.session, alert_console, self.inbox)
while not self.state.exit_requested:
try:
await asyncio.to_thread(self.inbox.pending_event.wait, timeout=1)
except asyncio.CancelledError:
return
try:
drain_and_render_incoming(self.session, alert_console, self.inbox)
except Exception as exc:
log.warning("Error draining incoming alerts: %s", exc)
async def _spinner_ticker(self) -> None:
# prompt_async's refresh_interval alone is not guaranteed to drive
# visible prompt redraws while patch_stdout(raw=True) is active and
# the LLM stream is writing rapidly. This task explicitly invalidates
# the prompt at 100 ms intervals so the braille glyph cycles smoothly.
tick_s = 0.1
was_streaming = False
while not self.state.exit_requested:
try:
await asyncio.sleep(tick_s)
except asyncio.CancelledError:
return
streaming = self.spinner.streaming
# Invalidate while streaming, plus one extra tick on the
# streaming->idle edge so the prompt repaints without the stale
# spinner/phase label instead of waiting for unrelated I/O.
if streaming or was_streaming:
self.prompt_invalidator()
was_streaming = streaming
@@ -0,0 +1,160 @@
"""Validated runtime context for interactive shell sessions."""
from __future__ import annotations
from typing import Self
from prompt_toolkit import PromptSession
from pydantic import BaseModel, ConfigDict, Field, InstanceOf, field_validator, model_validator
from core.agent_harness.session import SessionManager
from core.domain.alerts import inbox as _alert_inbox
from platform.observability.trace.spans import set_session_trace_sink
from surfaces.interactive_shell.runtime.core.state import (
ReplState,
SpinnerState,
create_repl_mutable_state,
)
from surfaces.interactive_shell.session.session import Session
from surfaces.interactive_shell.session.trace_sink import jsonl_trace_sink_for_session
class SessionBootstrapSpec(BaseModel):
"""Pydantic-enforced inputs for preparing a REPL session."""
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
session: InstanceOf[Session] = Field(default_factory=Session)
pt_session: PromptSession[str] | None = None
active_theme_name: str | None = None
hydrate_integrations: bool = True
persistent_tasks: bool = True
@field_validator("active_theme_name")
@classmethod
def _active_theme_name_must_not_be_blank(cls, value: str | None) -> str | None:
if value is not None and not value.strip():
raise ValueError("active_theme_name must not be blank")
return value
@model_validator(mode="after")
def apply_to_session(self) -> Self:
"""Apply the canonical startup mutations to the validated session.
Core bootstrap (persistent task registry + integration hydration) is
delegated to :class:`SessionManager`; the shell layers its own UI
concerns (theme, grounding providers, prompt history) on top.
"""
SessionManager().bootstrap(
self.session,
hydrate_integrations=self.hydrate_integrations,
persistent_tasks=self.persistent_tasks,
)
self.session.terminal.active_theme_name = self.active_theme_name or _current_theme_name()
if self.pt_session is not None:
self.session.terminal.prompt_history_backend = self.pt_session.history
return self
class ReplRuntimeContext(BaseModel):
"""Validated bundle shared by REPL entrypoints and the controller."""
model_config = ConfigDict(
arbitrary_types_allowed=True,
extra="forbid",
validate_assignment=True,
)
session: InstanceOf[Session]
state: InstanceOf[ReplState]
spinner: InstanceOf[SpinnerState]
pt_session: PromptSession[str] | None = None
inbox: _alert_inbox.AlertInbox | None = None
@model_validator(mode="before")
@classmethod
def apply_initial_mutable_state(cls, data: object) -> object:
"""Set the paired mutable state defaults through one canonical factory."""
if not isinstance(data, dict):
return data
if "state" in data and "spinner" in data:
return data
mutable_state = create_repl_mutable_state(
state=data.get("state"),
spinner=data.get("spinner"),
)
return {
**data,
"state": mutable_state.state,
"spinner": mutable_state.spinner,
}
@model_validator(mode="after")
def bind_prompt_history_backend(self) -> Self:
"""Keep session prompt-history state aligned with the prompt session."""
if self.pt_session is not None:
self.session.terminal.prompt_history_backend = self.pt_session.history
return self
def _current_theme_name() -> str:
from platform.terminal.theme import get_active_theme_name
return get_active_theme_name()
def prepare_repl_session(
session: Session | None = None,
*,
pt_session: PromptSession[str] | None = None,
active_theme_name: str | None = None,
hydrate_integrations: bool = True,
persistent_tasks: bool = True,
) -> Session:
"""Return a session with the same defaults used by REPL boot."""
spec = SessionBootstrapSpec(
session=session or Session(),
pt_session=pt_session,
active_theme_name=active_theme_name,
hydrate_integrations=hydrate_integrations,
persistent_tasks=persistent_tasks,
)
return spec.session
def create_repl_runtime_context(
session: Session | None = None,
*,
state: ReplState | None = None,
spinner: SpinnerState | None = None,
pt_session: PromptSession[str] | None = None,
inbox: _alert_inbox.AlertInbox | None = None,
active_theme_name: str | None = None,
hydrate_integrations: bool = True,
persistent_tasks: bool = True,
) -> ReplRuntimeContext:
"""Create the canonical validated context for a REPL controller."""
prepared_session = prepare_repl_session(
session,
pt_session=pt_session,
active_theme_name=active_theme_name,
hydrate_integrations=hydrate_integrations,
persistent_tasks=persistent_tasks,
)
set_session_trace_sink(jsonl_trace_sink_for_session(prepared_session))
mutable_state = create_repl_mutable_state(state=state, spinner=spinner)
return ReplRuntimeContext(
session=prepared_session,
state=mutable_state.state,
spinner=mutable_state.spinner,
pt_session=pt_session,
inbox=inbox,
)
__all__ = [
"ReplRuntimeContext",
"SessionBootstrapSpec",
"create_repl_runtime_context",
"prepare_repl_session",
]
@@ -0,0 +1,13 @@
"""Core runtime engine for the interactive shell.
Reusable session state lives in ``core.agent_harness.session`` and terminal runtime
context lives in ``interactive_shell.runtime.context``. This package owns the
remaining runtime engine concerns (mutable runtime state, prompt manager,
turn detection).
"""
from __future__ import annotations
from platform.common.task_registry import TaskRegistry
__all__ = ["TaskRegistry"]
@@ -0,0 +1,39 @@
"""Prompt-mediated confirmation waiting for interactive-shell turns.
A turn worker thread parks here while it waits for the user to answer a
confirmation prompt rendered by the REPL prompt loop. The wait is
cancel-safe: it polls the dispatch cancel event and raises
:class:`DispatchCancelled` instead of silently auto-confirming.
"""
from __future__ import annotations
import threading
from surfaces.interactive_shell.runtime.core.state import PROMPT_REFRESH_INTERVAL_S, ReplState
class DispatchCancelled(Exception):
"""Raised when in-flight dispatch is cancelled during confirmation."""
def request_confirmation_via_prompt(state: ReplState, prompt_text: str) -> str:
response_event = threading.Event()
state.begin_confirmation(response_event, prompt_text)
try:
while not response_event.is_set():
cancel = state.current_cancel_event
if cancel is not None and cancel.is_set():
raise DispatchCancelled("cancelled while awaiting confirmation")
response_event.wait(timeout=PROMPT_REFRESH_INTERVAL_S)
if not state.confirm_response:
raise DispatchCancelled("cancelled while awaiting confirmation")
return state.confirm_response[0]
finally:
state.clear_confirmation()
__all__ = [
"DispatchCancelled",
"request_confirmation_via_prompt",
]
@@ -0,0 +1,128 @@
"""Prompt lifecycle and rendering glue for the interactive REPL loop."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from prompt_toolkit import PromptSession
from prompt_toolkit.application import Application
from prompt_toolkit.formatted_text import ANSI
from rich.console import Console
from surfaces.interactive_shell.runtime.core.state import (
PROMPT_REFRESH_INTERVAL_S,
ReplState,
SpinnerState,
)
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui import input_prompt
from surfaces.interactive_shell.ui.components.cpr_stdin import (
drain_stale_cpr_bytes,
strip_cpr_sequences,
)
from surfaces.interactive_shell.ui.input_prompt import rendering as prompt_rendering
from surfaces.interactive_shell.ui.input_prompt.key_bindings import (
build_cancel_key_bindings,
install_session_key_bindings,
)
from surfaces.interactive_shell.ui.input_prompt.refresh import wire_prompt_refresh
from surfaces.interactive_shell.ui.input_prompt.style import refresh_prompt_theme
# Brief pause so a CPR reply still in flight lands in the stdin buffer before the
# non-blocking drain runs; without it the reply leaks into this prompt as literal bytes.
_CPR_SETTLE_SECONDS = 0.05
class PromptManager:
"""Own prompt-toolkit setup, prompt rendering, and prompt redraw hooks."""
def __init__(
self,
session: Session,
state: ReplState,
spinner: SpinnerState,
pt_session: PromptSession[str] | None = None,
) -> None:
self.session = session
self.state = state
self.spinner = spinner
self.pt_session = pt_session
self.pt_app: Application[str] | None = None
self.loop: asyncio.AbstractEventLoop | None = None
self._invalidate_prompt: Callable[[], None] | None = None
def setup(self) -> None:
if self.pt_session is None:
self.pt_session = input_prompt.build_prompt_session(self.session)
self.session.terminal.prompt_history_backend = self.pt_session.history
cancel_kb = build_cancel_key_bindings(self.state)
install_session_key_bindings(self.pt_session, cancel_kb)
self.pt_app = self.pt_session.app
self.loop = asyncio.get_running_loop()
self.session.terminal.prompt_app = self.pt_app
self.session.terminal.main_loop = self.loop
self.state.bind_loop(self.loop)
self._invalidate_prompt = wire_prompt_refresh(self.session, self.pt_app, self.loop)
@property
def invalidate_prompt(self) -> Callable[[], None]:
if self._invalidate_prompt is None:
raise RuntimeError("PromptManager.setup() must run before prompt invalidation")
return self._invalidate_prompt
def request_exit(self) -> None:
if self.pt_app is None or self.loop is None:
self.state.request_exit()
return
self.state.request_exit()
def _exit_prompt_app(attempts_left: int = 5) -> None:
if self.pt_app is not None and self.pt_app.is_running:
self.pt_app.exit()
return
if attempts_left > 0 and self.loop is not None:
self.loop.call_later(0.02, _exit_prompt_app, attempts_left - 1)
self.loop.call_soon_threadsafe(_exit_prompt_app)
def message_with_spinner(self) -> ANSI:
base = prompt_rendering._prompt_message(self.session).value
if self.state.is_awaiting_confirmation():
confirm_text = self.state.confirm_prompt_text
return ANSI(f"{confirm_text}\n{base}")
prefix = strip_cpr_sequences(
prompt_rendering.resolve_prompt_prefix_ansi(
inline_spinner=self.spinner.inline_spinner_ansi(),
idle_hint=prompt_rendering.resolve_idle_hint_ansi(self.session),
)
)
return ANSI(f"{prefix}\n{base}")
async def read_prompt_text(self) -> str:
if self.pt_session is None:
raise RuntimeError("PromptManager.setup() must run before reading prompts")
if self.session.terminal.pending_theme_refresh:
self.session.terminal.pending_theme_refresh = False
refresh_prompt_theme(self.session)
await asyncio.sleep(_CPR_SETTLE_SECONDS)
drain_stale_cpr_bytes()
prefilled = self.session.terminal.pop_pending_prompt_default()
if prefilled and self.session.terminal.pop_pending_autosubmit():
return prefilled
return await self.pt_session.prompt_async(
message=self.message_with_spinner,
bottom_toolbar=self.spinner.toolbar_ansi,
refresh_interval=PROMPT_REFRESH_INTERVAL_S,
placeholder=lambda: prompt_rendering.resolve_prompt_placeholder(self.session),
default=prefilled,
)
def render_submitted_prompt(self, console: Console, text: str) -> None:
prompt_rendering.render_submitted_prompt(console, self.session, text)
@@ -0,0 +1,268 @@
"""State models for the interactive shell UI runtime."""
from __future__ import annotations
import asyncio
import enum
import random
import threading
import time
from dataclasses import dataclass, field
from prompt_toolkit.application.current import get_app_or_none
from platform.terminal import theme as ui_theme
from surfaces.interactive_shell.ui.components.token_format import (
_CHARS_PER_TOKEN,
format_token_count_short,
)
# How often prompt-toolkit refreshes prompt callbacks and confirmation polling.
PROMPT_REFRESH_INTERVAL_S = 0.25
class TurnPhase(enum.Enum):
"""Explicit lifecycle phase of the current interactive-shell turn.
``phase`` is the declared turn intent and is authoritative for the
confirmation and cancelling states. ``is_dispatch_running()`` remains
derived from the asyncio task (the runtime truth of the in-flight turn),
because a task can settle on its own without an explicit transition.
"""
IDLE = "idle"
DISPATCHING = "dispatching"
AWAITING_CONFIRMATION = "awaiting_confirmation"
CANCELLING = "cancelling"
@dataclass
class ReplState:
"""Shared runtime state for prompt loop, queue worker, and cancel handlers.
Single source of truth for the active dispatch task, cancellation event,
confirmation lifecycle, exit request, and the explicit ``TurnPhase``.
Mutate turn state through the transition methods below rather than poking
raw fields, so ``phase`` stays consistent with the cancellation primitives.
"""
queue: asyncio.Queue[str] = field(default_factory=asyncio.Queue)
current_task: asyncio.Task[None] | None = None
current_cancel_event: threading.Event | None = None
loop: asyncio.AbstractEventLoop | None = None
exit_requested: bool = False
confirm_event: threading.Event | None = None
confirm_response: list[str] = field(default_factory=list)
confirm_prompt_text: str = ""
phase: TurnPhase = TurnPhase.IDLE
def is_dispatch_running(self) -> bool:
return self.current_task is not None and not self.current_task.done()
def is_awaiting_confirmation(self) -> bool:
return self.phase is TurnPhase.AWAITING_CONFIRMATION
def is_cancelling(self) -> bool:
return self.phase is TurnPhase.CANCELLING
def deliver_confirmation(self, answer: str) -> None:
if self.confirm_event is None:
return
self.confirm_response.append(answer)
self.confirm_event.set()
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
self.loop = loop
def request_exit(self) -> None:
self.exit_requested = True
def begin_confirmation(self, event: threading.Event, prompt_text: str = "") -> None:
# Reset the response list BEFORE publishing ``confirm_event`` so a
# concurrent ``deliver_confirmation`` cannot have its answer clobbered.
# ``phase`` is set before the publish so a parked worker is observable
# as awaiting confirmation the instant the event is visible.
self.confirm_response = []
self.confirm_prompt_text = prompt_text
self.phase = TurnPhase.AWAITING_CONFIRMATION
self.confirm_event = event
def clear_confirmation(self) -> None:
self.confirm_event = None
self.confirm_response = []
self.confirm_prompt_text = ""
# Only a normal confirmation completion returns to dispatching/idle; a
# cancel in progress must keep its CANCELLING phase.
if self.phase is TurnPhase.AWAITING_CONFIRMATION:
self.phase = TurnPhase.DISPATCHING if self.is_dispatch_running() else TurnPhase.IDLE
def start_dispatch(self, *, task: asyncio.Task[None], cancel_event: threading.Event) -> None:
self.current_task = task
self.current_cancel_event = cancel_event
self.phase = TurnPhase.DISPATCHING
def attach_turn_task(self, task: asyncio.Task[None]) -> None:
"""Mark a queued turn task as the active dispatch (queue worker entry)."""
self.current_task = task
self.phase = TurnPhase.DISPATCHING
def attach_cancel_event(self, cancel_event: threading.Event) -> None:
"""Park a cancel event for a dispatch that has no asyncio task."""
self.current_cancel_event = cancel_event
self.phase = TurnPhase.DISPATCHING
def clear_current_task(self, task: asyncio.Task[None] | None = None) -> None:
if task is None or self.current_task is task:
self.current_task = None
self.phase = TurnPhase.IDLE
def finish_dispatch(self, cancel_event: threading.Event) -> None:
if self.current_cancel_event is cancel_event:
self.current_cancel_event = None
self.phase = TurnPhase.IDLE
def cancel_current_dispatch(self) -> None:
# Mark the cancel intent first, but only when there is something to
# cancel, so an idle no-op call does not leave a stale CANCELLING phase.
if (
self.current_cancel_event is not None
or self.confirm_event is not None
or self.is_dispatch_running()
):
self.phase = TurnPhase.CANCELLING
if self.current_cancel_event is not None:
self.current_cancel_event.set()
if self.confirm_event is not None:
self.confirm_event.set()
task = self.current_task
if task is not None and not task.done():
if self.loop is not None:
self.loop.call_soon_threadsafe(task.cancel)
else:
task.cancel()
class SpinnerState:
"""Mutable state read by prompt callbacks for toolbar + inline spinner."""
_SPINNER_FRAMES = ("", "", "", "", "", "", "", "", "", "")
# One glyph advance per interval of *elapsed time*. The frame must be a
# pure function of the clock, never of how often the prompt message
# callback runs: prompt_toolkit evaluates the message several times per
# render pass (layout measurement + paint), so a per-call counter can land
# on the same frame every visible render and freeze the animation.
_FRAME_INTERVAL_S = 0.1
_THINKING_VERBS = (
"thinking",
"pondering",
"exploring",
"reasoning",
"considering",
"analysing",
"investigating",
"deliberating",
"ruminating",
"deducing",
"noodling",
)
def __init__(self) -> None:
self.streaming: bool = False
self.started_at: float = 0.0
self.bytes_in: int = 0
self._verb: str = self._THINKING_VERBS[0]
self.phase: str = ""
def start(self) -> None:
self.streaming = True
self.started_at = time.monotonic()
self.bytes_in = 0
self._verb = random.choice(self._THINKING_VERBS)
self.phase = ""
def set_phase(self, label: str) -> None:
"""Animate a caller-supplied phase label instead of a thinking verb.
Investigation stages (``/investigate``) dispatch deterministically, so
the turn-level "thinking" spinner never starts. The progress display
calls this to keep the prompt spinner cycling with the active pipeline
stage; it can be called repeatedly to advance the phase.
"""
if not self.streaming:
self.started_at = time.monotonic()
self._frame_idx = 0
self.streaming = True
self.phase = label
def stop(self) -> None:
self.streaming = False
self.phase = ""
def toolbar_ansi(self) -> str:
# Always return an empty string so prompt_toolkit's ConditionalContainer
# collapses the toolbar in every state. A visible toolbar causes
# prompt_toolkit to emit \033[6n (CPR) cursor-position queries on every
# refresh_interval; those responses leak into the vt100 input parser as
# literal keystrokes, corrupting the input field. Hiding the toolbar
# unconditionally also keeps its height at zero in both streaming and
# idle states, which prevents the one-row height delta that would cause
# prompt_toolkit to misplace the cursor and leave stale spinner lines on
# screen. Idle hints are surfaced through idle_hint_ansi() instead,
# which is rendered in the prompt message's reserved first line.
return ""
def idle_hint_ansi(self) -> str:
"""Dim hint line shown above the rule when no dispatch is running."""
hint = "/ for commands · ↑↓ history"
app = get_app_or_none()
if app is not None and app.current_buffer.text:
hint += " · esc to clear"
return f"{ui_theme.DIM_ANSI}{hint}{ui_theme.ANSI_RESET}"
def inline_spinner_ansi(self) -> str:
if not self.streaming:
return ""
elapsed = time.monotonic() - self.started_at
token_count = self.bytes_in // _CHARS_PER_TOKEN
frame_idx = int(elapsed / self._FRAME_INTERVAL_S)
glyph = self._SPINNER_FRAMES[frame_idx % len(self._SPINNER_FRAMES)]
if token_count > 0:
tokens_str = format_token_count_short(token_count)
suffix = f" ({elapsed:.0f}s · ↓ {tokens_str} tokens)"
else:
suffix = f" ({elapsed:.0f}s)"
label = self.phase or f"{self._verb}"
return (
f"{ui_theme.PROMPT_ACCENT_ANSI}{glyph} {label}{ui_theme.ANSI_RESET}"
f"{ui_theme.ANSI_DIM}{suffix} esc to cancel{ui_theme.ANSI_RESET}"
)
@dataclass(frozen=True)
class ReplMutableState:
"""Initial mutable state bundle shared by the interactive runtime."""
state: ReplState
spinner: SpinnerState
def create_repl_mutable_state(
*,
state: ReplState | None = None,
spinner: SpinnerState | None = None,
) -> ReplMutableState:
"""Return the canonical initial mutable state objects for a REPL runtime."""
return ReplMutableState(
state=state if state is not None else ReplState(),
spinner=spinner if spinner is not None else SpinnerState(),
)
__all__ = [
"PROMPT_REFRESH_INTERVAL_S",
"ReplMutableState",
"ReplState",
"SpinnerState",
"TurnPhase",
"create_repl_mutable_state",
]
@@ -0,0 +1,126 @@
"""Turn-result data model and the single owner of shell-turn accounting.
This module holds the shell's accounting side effects around the core
"facts only" turn-result models: action-agent analytics, terminal-turn
aggregate telemetry, prompt-recorder flushing, conversational-turn
persistence, and the final assistant-intent stamp.
"""
from __future__ import annotations
from dataclasses import dataclass
# The neutral "facts only" turn-result models live in the decoupled agent
# package; this module owns only the shell's accounting side effects over them.
from core.agent_harness.turns.turn_results import (
ShellTurnResult,
ToolCallingAccountingStatus,
ToolCallingTurnResult,
)
from platform.analytics.cli import capture_terminal_turn_summarized
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.utils.telemetry import PromptRecorder
@dataclass
class ShellTurnAccounting:
"""Single owner of a shell turn's accounting side effects.
Separates "what happened" (decided by the turn flow) from "how it is
accounted for": action-agent analytics, terminal-turn aggregate telemetry,
prompt-recorder flushing, conversational-turn persistence, and the final
assistant-intent stamp.
"""
session: Session
text: str
recorder: PromptRecorder | None
def record_action_result(self, action_result: ToolCallingTurnResult) -> None:
"""Emit action-agent analytics and update terminal-turn aggregates."""
self._record_action_analytics(action_result)
self._record_terminal_turn(action_result)
def finalize(self, result: ShellTurnResult) -> ShellTurnResult:
"""Flush the recorder, persist the turn, and stamp the session intent."""
self._flush_prompt_recorder(result)
if result.llm_run is not None:
self.session.record("cli_agent", self.text)
self.session.last_assistant_intent = result.final_intent
return result
def _record_action_analytics(self, action_result: ToolCallingTurnResult) -> None:
from platform.analytics.cli import (
capture_repl_execution_policy_decision,
capture_terminal_actions_executed,
capture_terminal_actions_planned,
)
if action_result.accounting_status == "not_run":
capture_terminal_actions_executed(
planned_count=0,
executed_count=0,
executed_success_count=0,
)
return
capture_terminal_actions_planned(
planned_count=action_result.planned_count,
has_unhandled_clause=action_result.has_unhandled_clause,
)
capture_repl_execution_policy_decision(
{
"policy_stage": "shell_action_agent",
"policy_trace": (
"agent_tool_calls" if action_result.planned_count else "assistant_handoff"
),
"planned_count": action_result.planned_count,
"has_unhandled_clause": action_result.has_unhandled_clause,
}
)
capture_terminal_actions_executed(
planned_count=action_result.planned_count,
executed_count=action_result.executed_count,
executed_success_count=action_result.executed_success_count,
)
def _record_terminal_turn(self, action_result: ToolCallingTurnResult) -> None:
fallback_to_llm = not action_result.handled
snapshot = self.session.terminal.metrics.record_turn(
executed_count=action_result.executed_count,
executed_success_count=action_result.executed_success_count,
fallback_to_llm=fallback_to_llm,
)
capture_terminal_turn_summarized(
planned_count=action_result.planned_count,
executed_count=action_result.executed_count,
executed_success_count=action_result.executed_success_count,
fallback_to_llm=fallback_to_llm,
session_turn_index=snapshot.turn_index,
session_fallback_count=snapshot.fallback_count,
session_action_success_percent=snapshot.action_success_percent,
session_fallback_rate_percent=snapshot.fallback_rate_percent,
)
def _flush_prompt_recorder(self, result: ShellTurnResult) -> None:
# Pending turn LLM/error state is consumed unconditionally so a turn
# that stages it can never leak it into a later turn's flush.
pending_run = self.session.terminal.pop_pending_turn_llm()
pending_error = self.session.terminal.pop_pending_turn_error()
if self.recorder is None:
return
if pending_error is not None:
self.recorder.set_error(pending_error[0], pending_error[1])
self.recorder.set_response(
result.assistant_response_text,
result.llm_run if result.llm_run is not None else pending_run,
)
self.recorder.flush()
__all__ = [
"ShellTurnAccounting",
"ShellTurnResult",
"ToolCallingAccountingStatus",
"ToolCallingTurnResult",
]
@@ -0,0 +1,47 @@
"""Pure text classifiers for interactive-shell prompt turns."""
from __future__ import annotations
import re
_INTERVENTION_CORRECTION_RE = re.compile(
r"("
r"no(?=[,.!?]|$)"
r"|nope\b"
r"|nvm\b"
r"|nevermind\b|never\s*mind\b"
r"|wrong\b"
r"|wait(?=[,.!?]|$)"
r"|stop(?=[,.!?]|$)"
r"|actually\b"
r"|scratch\s+that\b"
r"|instead(?=[,.!?]|$)"
r"|(?:let'?s\s+)?do\s+[^.\n]{1,60}\s+instead\b"
r"|try\s+[^.\n]{1,60}\s+instead\b"
r")",
re.IGNORECASE,
)
_CONFIRMATION_TOKENS: frozenset[str] = frozenset({"", "y", "yes", "n", "no"})
_CANCEL_REQUEST_TOKENS: frozenset[str] = frozenset({"/cancel", "/stop", "/abort"})
def looks_like_confirmation_answer(text: str | None) -> bool:
return (text or "").strip().lower() in _CONFIRMATION_TOKENS
def looks_like_cancel_request(text: str | None) -> bool:
return (text or "").strip().lower() in _CANCEL_REQUEST_TOKENS
def looks_like_correction(text: str) -> bool:
stripped = text.lstrip()
if not stripped or stripped.startswith("```"):
return False
return _INTERVENTION_CORRECTION_RE.match(stripped[:80]) is not None
__all__ = [
"looks_like_cancel_request",
"looks_like_confirmation_answer",
"looks_like_correction",
]
@@ -0,0 +1,37 @@
"""Prompt input event reader for the interactive shell runtime."""
from surfaces.interactive_shell.runtime.input.actions import (
QUEUE_DURING_CONFIRMATION_WARNING,
CancelTurn,
CloseShell,
DeliverConfirmation,
IgnoreInput,
InputAction,
ShellInputSnapshot,
SubmitTurn,
decide_input_action,
)
from surfaces.interactive_shell.runtime.input.events import (
InputCancelled,
InputClosed,
InputEvent,
InputSubmitted,
)
from surfaces.interactive_shell.runtime.input.prompt_input_reader import PromptInputReader
__all__ = [
"CancelTurn",
"CloseShell",
"DeliverConfirmation",
"IgnoreInput",
"InputAction",
"InputCancelled",
"InputClosed",
"InputEvent",
"InputSubmitted",
"PromptInputReader",
"QUEUE_DURING_CONFIRMATION_WARNING",
"ShellInputSnapshot",
"SubmitTurn",
"decide_input_action",
]
@@ -0,0 +1,109 @@
"""Pure input-action decisions for the interactive shell controller."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from surfaces.interactive_shell.runtime.core.turn_detection import (
looks_like_cancel_request,
looks_like_confirmation_answer,
)
from surfaces.interactive_shell.runtime.input.events import (
InputCancelled,
InputClosed,
InputEvent,
InputSubmitted,
)
QUEUE_DURING_CONFIRMATION_WARNING = (
"[dim](type y/N to confirm the pending action; your input has been queued for after)[/]"
)
@dataclass(frozen=True)
class ShellInputSnapshot:
exit_requested: bool
dispatch_running: bool
awaiting_confirmation: bool
@dataclass(frozen=True)
class IgnoreInput:
pass
@dataclass(frozen=True)
class CloseShell:
pass
@dataclass(frozen=True)
class CancelTurn:
submitted_text: str | None = None
@dataclass(frozen=True)
class DeliverConfirmation:
text: str
@dataclass(frozen=True)
class SubmitTurn:
text: str
wait_until_idle: bool = False
warning: str | None = None
InputAction = IgnoreInput | CloseShell | CancelTurn | DeliverConfirmation | SubmitTurn
def decide_input_action(
event: InputEvent,
snapshot: ShellInputSnapshot,
*,
needs_exclusive_stdin: Callable[[str], bool],
) -> InputAction:
"""Interpret one prompt event without mutating runtime state."""
match event:
case InputClosed():
return CloseShell()
case InputCancelled():
return CancelTurn()
case InputSubmitted(text):
if snapshot.exit_requested or not text:
return IgnoreInput()
stripped = text.strip()
if not stripped:
return IgnoreInput()
if snapshot.dispatch_running and looks_like_cancel_request(stripped):
return CancelTurn(submitted_text=stripped)
if snapshot.awaiting_confirmation:
if looks_like_confirmation_answer(stripped):
return DeliverConfirmation(text=stripped)
return SubmitTurn(
text=stripped,
warning=QUEUE_DURING_CONFIRMATION_WARNING,
)
return SubmitTurn(
text=stripped,
wait_until_idle=needs_exclusive_stdin(stripped),
)
raise AssertionError(f"Unhandled input event: {event!r}")
__all__ = [
"CancelTurn",
"CloseShell",
"DeliverConfirmation",
"IgnoreInput",
"InputAction",
"QUEUE_DURING_CONFIRMATION_WARNING",
"ShellInputSnapshot",
"SubmitTurn",
"decide_input_action",
]
@@ -0,0 +1,26 @@
"""Explicit input events consumed by the interactive shell loop."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class InputSubmitted:
text: str
@dataclass(frozen=True)
class InputCancelled:
pass
@dataclass(frozen=True)
class InputClosed:
pass
InputEvent = InputSubmitted | InputCancelled | InputClosed
__all__ = ["InputCancelled", "InputClosed", "InputEvent", "InputSubmitted"]
@@ -0,0 +1,74 @@
"""Convert prompt-toolkit terminal behavior into shell input events."""
from __future__ import annotations
from rich.console import Console
from platform.terminal.prompt_support import (
print_session_resume_hint,
repl_prompt_note_ctrl_c,
repl_reset_ctrl_c_gate,
)
from surfaces.interactive_shell.runtime.core.prompt_manager import PromptManager
from surfaces.interactive_shell.runtime.core.state import ReplState
from surfaces.interactive_shell.runtime.input.events import (
InputCancelled,
InputClosed,
InputEvent,
InputSubmitted,
)
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui import DIM
from surfaces.interactive_shell.ui.components.cpr_stdin import (
contains_cpr_sequence,
strip_cpr_sequences,
)
class PromptInputReader:
"""Read prompt text and hide terminal-specific control flow from the loop."""
def __init__(
self,
prompt: PromptManager,
state: ReplState,
session: Session,
console: Console,
) -> None:
self.prompt = prompt
self.state = state
self.session = session
self.console = console
async def read(self) -> InputEvent:
while True:
try:
text = await self.prompt.read_prompt_text()
except EOFError:
if self.state.is_dispatch_running():
return InputCancelled()
self._render_session_resume_hint()
return InputClosed()
except KeyboardInterrupt:
if self.state.is_dispatch_running():
return InputCancelled()
if repl_prompt_note_ctrl_c(self.console, self.session.session_id):
return InputClosed()
return InputCancelled()
repl_reset_ctrl_c_gate()
raw_text = text
text = strip_cpr_sequences(text)
if not text.strip() and contains_cpr_sequence(raw_text):
continue
return InputSubmitted(text)
def _render_session_resume_hint(self) -> None:
if not self.session.session_id:
return
self.console.print()
print_session_resume_hint(self.console, self.session.session_id)
self.console.print(f"[{DIM}]Goodbye![/]")
__all__ = ["PromptInputReader"]
@@ -0,0 +1,197 @@
"""Gather integration evidence for a conversational shell answer.
The bounded think -> call-tools -> observe loop lives in the decoupled
:func:`core.agent_harness.turns.evidence_driver.gather_tool_evidence`. This module is the terminal adapter:
it renders each gathering step to the console and persists the gathered tool
calls into the shell's session storage, then hands the collected observation back
to :func:`interactive_shell.runtime.answer_turn.answer_shell_question`.
"""
from __future__ import annotations
import contextlib
import json
from typing import Any
from rich.console import Console
from rich.markup import escape
from core.agent_harness.turns import evidence_driver
from core.agent_harness.turns.evidence_driver import GatherAgentFactory
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui import DIM
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
from surfaces.shared.tool_labels import tool_short_label, tool_source_label
# Cap so a chatty tool result can't blow up persistence writes.
_MAX_PER_TOOL_CHARS = 4_000
# Keys most likely to distinguish back-to-back calls to the same tool.
_GATHER_INPUT_HINT_KEYS: tuple[str, ...] = (
"metric_name",
"query",
"search",
"filter",
"expression",
"promql",
"service_name",
"owner",
"repo",
"log_group",
"monitor_id",
"alert_id",
"issue_id",
"trace_id",
"span_id",
"dashboard_uid",
"panel_id",
"from",
"to",
"time_range",
)
class _ShellGatherErrorReporter:
"""Minimal :class:`core.agent_harness.ports.ErrorReporter` over ``report_exception``."""
def report(self, exc: BaseException, *, context: str, expected: bool = False) -> None:
report_exception(exc, context=context, expected=expected)
def _truncate_hint(text: str, *, max_len: int = 48) -> str:
compact = " ".join(text.split())
if len(compact) <= max_len:
return compact
return f"{compact[: max_len - 1]}"
def _tool_input_hint(tool_input: Any) -> str:
if not isinstance(tool_input, dict):
return ""
hints: list[str] = []
seen: set[str] = set()
for key in _GATHER_INPUT_HINT_KEYS:
value = tool_input.get(key)
if value in (None, "", [], {}):
continue
rendered = _truncate_hint(str(value))
if not rendered or rendered in seen:
continue
seen.add(rendered)
hints.append(rendered)
if len(hints) >= 2:
break
return " · ".join(hints)
def _format_gathering_progress_line(
tool_name: str,
tool_input: Any,
*,
repeat_index: int,
) -> str:
source = tool_source_label(tool_name)
label = tool_short_label(tool_name, source)
call_display = f"{source} · {label}" if label else source
if repeat_index > 1:
call_display = f"{call_display} ({repeat_index})"
safe_display = escape(call_display)
hint = _tool_input_hint(tool_input)
if hint:
return f"· gathering via {safe_display}{escape(hint)}"
return f"· gathering via {safe_display}"
def _resolve_gather_integrations(
session: Session,
message: str,
resolved_integrations: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Resolve gather integrations through the decoupled agent helper."""
return evidence_driver._resolve_gather_integrations( # noqa: SLF001
session, message, resolved_integrations=resolved_integrations
)
def _truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[:limit] + f"\n…[truncated, {len(text)} chars total]"
def _persist_tool_calls(session: Session, executed: list[tuple[Any, Any]]) -> None:
"""Record each gathered tool-call result into the session log.
Arguments and results are redacted and bounded before writing; failures are
swallowed so logging never breaks the turn.
"""
from core.agent_harness.session import default_session_storage
from platform.observability.trace.redaction import redact_sensitive
storage = default_session_storage()
for tc, output in executed:
with contextlib.suppress(Exception):
body = (
output
if isinstance(output, str)
else json.dumps(redact_sensitive(output), default=str)
)
arguments = (
redact_sensitive(tc.input) if isinstance(tc.input, dict) else {"value": tc.input}
)
storage.append_tool_call(
session.session_id,
tool=str(tc.name),
arguments=arguments,
result=_truncate(body, _MAX_PER_TOOL_CHARS),
ok=not (isinstance(output, dict) and "error" in output),
)
def gather_integration_tool_evidence(
message: str,
session: Session,
console: Console,
*,
is_tty: bool | None = None,
agent_factory: GatherAgentFactory | None = None,
resolved_integrations: dict[str, Any] | None = None,
) -> str | None:
"""Run a bounded tool-calling loop and return collected evidence, or None.
Returns a formatted observation block when at least one tool was executed;
otherwise ``None`` so the caller falls back to the normal text-only answer.
``resolved_integrations`` is the turn's resolved view; it is forwarded so the
gather phase reuses it instead of resolving again.
"""
tool_call_counts: dict[str, int] = {}
def on_progress(kind: str, data: dict[str, Any]) -> None:
if kind == "tool_start":
name = str(data.get("name", "")).strip() or "tool"
tool_call_counts[name] = tool_call_counts.get(name, 0) + 1
line = _format_gathering_progress_line(
name,
data.get("input"),
repeat_index=tool_call_counts[name],
)
console.print(f"[{DIM}]{line}[/]")
elif kind == "gather_cancelled":
console.print(f"[{DIM}]· gathering cancelled[/]")
def persist(executed: list[tuple[Any, Any]]) -> None:
_persist_tool_calls(session, executed)
return evidence_driver.gather_tool_evidence(
message,
session,
on_progress=on_progress,
persist=persist,
error_reporter=_ShellGatherErrorReporter(),
is_tty=is_tty,
agent_factory=agent_factory,
resolved_integrations=resolved_integrations,
)
__all__ = ["gather_integration_tool_evidence"]
@@ -0,0 +1,170 @@
"""REPL adapters for session investigation streaming and action-tool launch."""
from __future__ import annotations
import threading
from collections.abc import Callable, Iterator
from typing import Any
from rich.console import Console
from core.domain.stream import StreamEvent
from platform.common.task_types import TaskRecord
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
from surfaces.interactive_shell.ui.foreground_investigation import run_foreground_investigation
from tools.interactive_shell.shared.execution_policy import ExecutionPolicyResult
from tools.interactive_shell.shared.investigation_launch import (
ForegroundInvestigationResult,
InvestigationLaunchPorts,
)
from tools.investigation import session_runner
def repl_foreground_renderer() -> session_runner.StreamRendererFn:
"""Return a renderer that streams investigation progress to the REPL terminal."""
from surfaces.cli.ui.renderer import StreamRenderer
def _render(events: Iterator[StreamEvent]) -> dict[str, Any]:
renderer = StreamRenderer(local=True)
return dict(renderer.render_stream(events))
return _render
def repl_background_renderer() -> session_runner.StreamRendererFn:
"""Return a silent renderer for background investigations."""
from surfaces.cli.ui.renderer import StreamRenderer
from surfaces.interactive_shell.ui.output import reset_tracker, set_silent_tracker
def _render(events: Iterator[StreamEvent]) -> dict[str, Any]:
set_silent_tracker()
try:
renderer = StreamRenderer(local=True, display=False)
return dict(renderer.render_stream(events))
finally:
reset_tracker()
return _render
def run_investigation_for_session(
*,
alert_text: str,
context_overrides: dict[str, Any] | None = None,
cancel_requested: threading.Event | None = None,
) -> dict[str, Any]:
"""Run a foreground streaming investigation in the REPL."""
return session_runner.run_investigation_for_session(
alert_text=alert_text,
context_overrides=context_overrides,
cancel_requested=cancel_requested,
render_stream=repl_foreground_renderer(),
)
def run_sample_alert_for_session(
*,
template_name: str = "generic",
context_overrides: dict[str, Any] | None = None,
cancel_requested: threading.Event | None = None,
) -> dict[str, Any]:
"""Run a foreground sample-alert investigation in the REPL."""
return session_runner.run_sample_alert_for_session(
template_name=template_name,
context_overrides=context_overrides,
cancel_requested=cancel_requested,
render_stream=repl_foreground_renderer(),
)
def run_investigation_for_session_background(
*,
alert_text: str,
context_overrides: dict[str, Any] | None = None,
cancel_requested: threading.Event | None = None,
) -> dict[str, Any]:
"""Run a silent background investigation in the REPL."""
return session_runner.run_investigation_for_session_background(
alert_text=alert_text,
context_overrides=context_overrides,
cancel_requested=cancel_requested,
render_stream=repl_background_renderer(),
)
def run_sample_alert_for_session_background(
*,
template_name: str = "generic",
context_overrides: dict[str, Any] | None = None,
cancel_requested: threading.Event | None = None,
) -> dict[str, Any]:
"""Run a silent background sample-alert investigation in the REPL."""
return session_runner.run_sample_alert_for_session_background(
template_name=template_name,
context_overrides=context_overrides,
cancel_requested=cancel_requested,
render_stream=repl_background_renderer(),
)
class ReplInvestigationLaunchPorts:
"""Default REPL ports for investigation-style action tools."""
def execution_allowed(
self,
*,
policy: ExecutionPolicyResult,
session: Session,
console: Console,
action_summary: str,
confirm_fn: Callable[[str], str] | None,
is_tty: bool | None,
action_already_listed: bool,
) -> bool:
return execution_allowed(
policy,
session=session,
console=console,
action_summary=action_summary,
confirm_fn=confirm_fn,
is_tty=is_tty,
action_already_listed=action_already_listed,
)
def run_foreground_investigation(
self,
*,
session: Session,
console: Console,
task_command: str,
run: Callable[[TaskRecord], dict[str, object]],
exception_context: str,
target: str,
) -> ForegroundInvestigationResult:
outcome = run_foreground_investigation(
session=session,
console=console,
task_command=task_command,
run=run,
exception_context=exception_context,
target=target,
)
return ForegroundInvestigationResult(status=outcome.status)
def repl_investigation_launch_ports() -> InvestigationLaunchPorts:
"""Return REPL investigation launch ports for action tools."""
return ReplInvestigationLaunchPorts()
__all__ = [
"ReplInvestigationLaunchPorts",
"repl_background_renderer",
"repl_foreground_renderer",
"repl_investigation_launch_ports",
"run_investigation_for_session",
"run_investigation_for_session_background",
"run_sample_alert_for_session",
"run_sample_alert_for_session_background",
]
@@ -0,0 +1,114 @@
"""Compose one interactive-shell turn from its action/gather/answer adapters.
Adapter-only: binds the shell's action-turn (``action_turn``), gather pass
(``integration_tool_gathering``), and answer (``answer_turn``) adapters to the
surface-agnostic ``run_turn`` engine. Each adapter owns its own binding; this
file only composes them and attaches turn accounting. The injection contracts
live in ``turn_seams``.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Unpack
from rich.console import Console
from core.agent_harness.ports import OutputSink
from core.agent_harness.turns.orchestrator import run_turn
from core.agent_harness.turns.turn_plan import TurnPlan
from core.agent_harness.turns.turn_results import ShellTurnResult, ToolCallingTurnResult
from core.execution import ToolExecutionHooks
from surfaces.interactive_shell.runtime.action_turn import run_action_tool_turn
from surfaces.interactive_shell.runtime.agent_harness_adapters import resolve_output_sink
from surfaces.interactive_shell.runtime.answer_turn import answer_shell_question
from surfaces.interactive_shell.runtime.core.turn_accounting import ShellTurnAccounting
from surfaces.interactive_shell.runtime.integration_tool_gathering import (
gather_integration_tool_evidence,
)
from surfaces.interactive_shell.runtime.turn_seams import (
AnswerKwargs,
AnswerShellQuestion,
GatherEvidence,
RunActionToolTurn,
)
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.utils.telemetry import LlmRunInfo, PromptRecorder
def execute_shell_turn(
text: str,
session: Session,
console: Console,
*,
recorder: PromptRecorder | None,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
request_exit: Callable[[], None] | None = None,
execute_actions: RunActionToolTurn | None = None,
gather_evidence: GatherEvidence | None = None,
answer_agent: AnswerShellQuestion | None = None,
output: OutputSink | None = None,
tool_hooks: ToolExecutionHooks | None = None,
) -> ShellTurnResult:
"""Execute one submitted interactive-shell turn.
The action driver, gather pass, and conversational assistant default to the
shell adapters but are overridable via ``execute_actions`` / ``gather_evidence``
/ ``answer_agent`` (the test injection seams, typed in ``turn_seams``). They are
bound to the live ``session``/``console`` here and handed to
:func:`core.agent_harness.turns.orchestrator.run_turn`, which performs
the pure path routing.
"""
_execute = execute_actions or run_action_tool_turn
_gather = gather_evidence or gather_integration_tool_evidence
_answer = answer_agent or answer_shell_question
accounting = ShellTurnAccounting(session=session, text=text, recorder=recorder)
resolved_output = resolve_output_sink(console, output)
def execute_bound(
t: str,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
turn_plan: TurnPlan | None = None,
) -> ToolCallingTurnResult:
return _execute(
t,
session,
console,
confirm_fn=confirm_fn,
is_tty=is_tty,
request_exit=request_exit,
turn_plan=turn_plan,
output=resolved_output,
tool_hooks=tool_hooks,
)
def answer_bound(t: str, **kwargs: Unpack[AnswerKwargs]) -> LlmRunInfo | None:
# run_turn controls which keys are present (it omits tool_observation_on_screen
# on the plain path); AnswerKwargs types them without forcing presence.
return _answer(t, session, console, output=resolved_output, **kwargs)
def gather_bound(
t: str,
*,
is_tty: bool | None = None,
turn_plan: TurnPlan | None = None,
) -> str | None:
resolved = turn_plan.resolved_integrations if turn_plan is not None else None
return _gather(t, session, console, is_tty=is_tty, resolved_integrations=resolved)
return run_turn(
text,
session,
execute_actions=execute_bound,
answer=answer_bound,
gather=gather_bound,
accounting=accounting,
confirm_fn=confirm_fn,
is_tty=is_tty,
)
__all__ = ["execute_shell_turn"]
@@ -0,0 +1 @@
"""Interactive shell startup and first-launch gates."""
@@ -0,0 +1,303 @@
"""First-launch GitHub login gate.
On the first interactive launch of ``opensre`` (all platforms), the user is
prompted to sign in to GitHub via device flow unless they skip, are in CI/CD, a
test harness, or a non-interactive session. The sign-in runs the hosted GitHub
MCP setup, persists the integration, and propagates the authenticated GitHub
username to PostHog.
Escape hatch: ``OPENSRE_SKIP_GITHUB_LOGIN=1`` bypasses the gate so a GitHub
outage or a disabled device flow can never permanently lock anyone out. The gate
is also auto-bypassed in CI/test environments and when stdin is not a TTY.
"""
from __future__ import annotations
import logging
import os
import sys
import time
from rich.console import Console
from rich.markup import escape
from config.repl_config import read_github_login_deferred, write_github_login_deferred
from platform.analytics.cli import capture_github_login_completed
from platform.analytics.source import is_test_run
from platform.terminal.theme import DEVICE_CODE
from surfaces.interactive_shell.ui import repl_tty_interactive
_SKIP_ENV_VAR = "OPENSRE_SKIP_GITHUB_LOGIN"
_TRUTHY = frozenset({"1", "true", "yes", "on"})
_SIGN_IN_CHOICE = "sign_in"
_SKIP_CHOICE = "skip"
def _skip_requested() -> bool:
return os.getenv(_SKIP_ENV_VAR, "").strip().lower() in _TRUTHY
def _github_login_explicitly_bypassed() -> bool:
"""Cheap check for contexts where gate errors should not block startup."""
if _skip_requested():
return True
if os.getenv("OPENSRE_INVESTIGATION_SOURCE", "").strip().lower() == "test":
return True
if os.getenv("OPENSRE_IS_TEST", "0").strip() == "1":
return True
if os.getenv("PYTEST_CURRENT_TEST"):
return True
if os.getenv("GITHUB_ACTIONS", "").strip().lower() == "true":
return True
ci_value = os.getenv("CI", "").strip().lower()
if ci_value in {"1", "true", "yes"}:
return True
try:
return not sys.stdin.isatty()
except Exception:
return True
def _github_already_configured() -> bool:
from integrations.github.mcp import github_integration_is_configured
return github_integration_is_configured()
def should_require_github_login() -> bool:
"""Return True when the first-launch GitHub login prompt must run now."""
if _skip_requested():
return False
if read_github_login_deferred():
return False
if is_test_run():
return False
if not repl_tty_interactive():
return False
# GitHub being configured is the authoritative bypass. We intentionally do
# NOT consult a first-launch "completion" marker here: a stale marker must
# never let the REPL start once the GitHub integration has been removed
# (e.g. via ``/integrations remove github``). Re-checking the store is cheap,
# so the gate always re-runs when GitHub is not currently configured.
return not _github_already_configured()
def clear_github_login_deferral() -> None:
"""Clear a saved skip so removing GitHub can re-prompt on the next launch."""
if not read_github_login_deferred():
return
write_github_login_deferred(False)
def _propagate_username(username: str) -> None:
if not username:
return
# ``authenticate_and_configure_github`` already calls identify_github_username;
# only emit the one-time login lifecycle event here.
capture_github_login_completed(username)
def _print_intro(console: Console) -> None:
console.print()
console.print("[bold]Connect GitHub to get started[/bold]")
console.print(
"OpenSRE needs read access to your GitHub repositories to investigate "
"incidents against your source. Sign in once with your browser."
)
console.print(
"[dim](Escape to skip for now, or set "
f"{_SKIP_ENV_VAR}=1 if GitHub sign-in is unavailable.)[/dim]"
)
def _show_device_code(console: Console, code: object) -> None:
from integrations.github.mcp_oauth import GitHubDeviceCode
if not isinstance(code, GitHubDeviceCode):
return
user_code = escape(code.user_code)
console.print()
console.print(f" 1. Your browser will open [underline]{code.verification_uri}[/underline]")
console.print(" (if it doesn't open automatically, visit that URL yourself).")
console.print(f" 2. Enter this one-time code when GitHub asks: [{DEVICE_CODE}]{user_code}[/]")
console.print(" 3. Approve the request for OpenSRE.")
console.print()
console.print(
" [dim]Waiting for you to approve in the browser… (Escape or Ctrl-C to skip)[/dim]"
)
def _print_skip_guidance(console: Console) -> None:
console.print()
console.print(
"[dim]Skipped GitHub sign-in. Connect later with "
"[bold]/integrations setup[/bold] or [bold]/mcp connect github[/bold].[/dim]"
)
def _defer_github_login() -> None:
write_github_login_deferred(True)
def _sleep_until_or_cancel(seconds: float) -> None:
"""Sleep up to ``seconds``, raising ``KeyboardInterrupt`` when the user skips."""
if seconds <= 0 or not sys.stdin.isatty():
time.sleep(seconds)
return
if os.name == "nt":
import msvcrt
from surfaces.interactive_shell.ui.components.key_reader import read_key_windows
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if msvcrt.kbhit() and read_key_windows() == "cancel": # type: ignore[attr-defined]
raise KeyboardInterrupt
time.sleep(0.05)
return
import select
import termios
import tty
from surfaces.interactive_shell.ui.components.key_reader import read_key_unix
fd = sys.stdin.fileno()
old_attrs = termios.tcgetattr(fd) # type: ignore[attr-defined]
try:
tty.setraw(fd) # type: ignore[attr-defined]
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
if remaining <= 0:
return
ready, _, _ = select.select([fd], [], [], min(remaining, 0.15))
if not ready:
continue
if read_key_unix() == "cancel":
raise KeyboardInterrupt
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs) # type: ignore[attr-defined]
def _offer_github_login(_console: Console) -> bool:
"""Return True when the user wants to start browser sign-in."""
import questionary
try:
choice = questionary.select(
"Connect GitHub now?",
choices=[
questionary.Choice(
"Sign in with GitHub (opens browser)",
value=_SIGN_IN_CHOICE,
),
questionary.Choice("Skip for now", value=_SKIP_CHOICE),
],
default=_SIGN_IN_CHOICE,
).ask()
except (EOFError, KeyboardInterrupt):
return False
if choice is None:
return False
return bool(choice == _SIGN_IN_CHOICE)
def _ask_retry(_console: Console) -> bool:
import questionary
try:
answer = questionary.confirm("Try GitHub sign-in again?", default=True).ask()
except (EOFError, KeyboardInterrupt):
return False
if answer is None:
return False
return bool(answer)
def _attempt_login(console: Console) -> str:
"""Run one login attempt. Returns ``"success"``, ``"failed"``, or ``"skipped"``."""
from integrations.github.login import authenticate_and_configure_github
from integrations.github.mcp_oauth import GitHubDeviceFlowError
try:
result = authenticate_and_configure_github(
on_prompt=lambda code: _show_device_code(console, code),
poll_sleep=_sleep_until_or_cancel,
)
except (EOFError, KeyboardInterrupt):
console.print("\nSkipped GitHub sign-in.")
return "skipped"
except GitHubDeviceFlowError as err:
console.print(f"[yellow]GitHub sign-in is unavailable:[/yellow] {err}")
return "failed"
except Exception as err: # network/transport issues
console.print(f"[yellow]GitHub sign-in failed:[/yellow] {err}")
return "failed"
if result.ok:
clear_github_login_deferral()
# Persisting the GitHub integration (done inside
# ``authenticate_and_configure_github``) is what suppresses the gate on
# subsequent launches — there is no separate completion marker to write.
_propagate_username(result.username)
who = f"@{result.username}" if result.username else "your GitHub account"
console.print(f"[bold]Connected.[/bold] Signed in as {who}.")
return "success"
console.print(f"[yellow]Could not verify GitHub access:[/yellow] {result.detail}")
return "failed"
def require_github_login_on_first_launch(console: Console | None = None) -> bool:
"""Run the first-launch GitHub login prompt.
Returns True when the caller should proceed into the REPL (login succeeded or
the user skipped), and False only when startup must abort.
"""
con = console or Console(highlight=False)
_print_intro(con)
if not _offer_github_login(con):
_defer_github_login()
_print_skip_guidance(con)
return True
while True:
outcome = _attempt_login(con)
if outcome == "success":
return True
if outcome == "skipped":
_defer_github_login()
_print_skip_guidance(con)
return True
if not _ask_retry(con):
_defer_github_login()
_print_skip_guidance(con)
return True
def require_startup_github_login(console: Console) -> bool:
"""Return True when startup may proceed past the GitHub login gate.
On an unexpected gate error we deliberately do NOT fail open into the REPL:
that would let a gate bug silently skip sign-in. Instead we only allow
startup when an explicit, documented bypass applies.
"""
try:
if not should_require_github_login():
return True
return require_github_login_on_first_launch(console)
except Exception:
logging.getLogger(__name__).warning(
"First-launch GitHub login gate failed.",
exc_info=True,
)
if _github_login_explicitly_bypassed():
return True
console.print(
"GitHub sign-in could not run. "
f"Set [bold]{_SKIP_ENV_VAR}=1[/bold] to bypass this, then relaunch "
"[bold]opensre[/bold]."
)
return False
@@ -0,0 +1,54 @@
"""Non-interactive initial-input replay for REPL startup."""
from __future__ import annotations
from rich.console import Console
from platform.analytics.repl_context import bound_repl_turn_context
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui.banner import render_banner
from surfaces.interactive_shell.ui.input_prompt.rendering import render_submitted_prompt
from surfaces.interactive_shell.utils.telemetry import PromptRecorder
_TURN_KIND = "agent"
def run_initial_input(
initial_input: str,
session: Session,
) -> int:
# Imported lazily so importing this module during REPL boot (main.py imports
# ``run_initial_input`` at top) does not pull the harness/turn-execution
# stack into the base import path when there is no initial input to replay.
from surfaces.interactive_shell.runtime.shell_turn_execution import execute_shell_turn
console = Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
render_banner(console)
for line in initial_input.splitlines():
stripped = line.strip()
if not stripped:
continue
render_submitted_prompt(console, session, stripped)
recorder = PromptRecorder.start(session=session, text=stripped, turn_kind=_TURN_KIND)
with bound_repl_turn_context(
session_id=session.session_id,
turn_kind=_TURN_KIND,
prompt_turn_id=recorder.turn_id if recorder is not None else None,
):
execute_shell_turn(
stripped,
session,
console,
recorder=recorder,
confirm_fn=None,
is_tty=False,
)
return 0
__all__ = ["run_initial_input"]
@@ -0,0 +1,132 @@
"""Execute planned opensre CLI actions.
Pure subprocess execution, planning, and orchestration for shell / synthetic /
Claude Code / opensre CLI action tools live under ``tools.interactive_shell``
(``subprocess/``, ``shell/``, ``synthetic/``, ``implementation/``, ``cli/``).
This package keeps Rich relay helpers in ``task_streaming``, the REPL presenter
in ``repl_presenter``, and backward-compatible surface adapters such as
``opensre_cli_runner`` for slash parity and legacy test monkeypatch paths.
Shell command execution lives in ``tools.interactive_shell.shell`` (parsing,
policy, ``execute_shell_command``, and the ``run_shell_command`` / ``run_cd`` /
``run_pwd`` runner); it is intentionally not re-exported here. Synthetic test
execution lives in ``tools.interactive_shell.synthetic`` (the
``run_synthetic_test`` / ``watch_synthetic_subprocess`` runner), Claude Code
implementation execution lives in ``tools.interactive_shell.implementation.claude_code_executor``
(``run_claude_code_implementation``), and sample-alert / free-text investigation
execution lives in ``tools.interactive_shell.actions.sample_alert`` /
``tools.interactive_shell.actions.investigation`` (``run_sample_alert`` /
``run_text_investigation``); none are re-exported here. Shared stdlib subprocess
primitives live in ``tools.interactive_shell.subprocess``; Rich stream relay
remains in ``task_streaming``.
Public API is stable: all names exported below are importable directly from
``subprocess_runner`` and will remain so regardless of internal submodule changes.
Stdlib modules ``os``, ``subprocess``, and ``threading`` are re-imported here so
that tests can patch them via the full ``subprocess_runner.<module>.<attr>`` path
(e.g. ``subprocess_runner.subprocess.Popen``). Since these are module singletons in
``sys.modules``, patching via this attribute also affects the actual call sites
inside the submodules.
"""
from __future__ import annotations
# Stdlib singletons — imported so that monkeypatch paths resolve correctly in tests:
# ``"…subprocess_runner.os.chdir"``, ``"…subprocess_runner.subprocess.Popen"``,
# ``"…subprocess_runner.threading.Thread"``, ``"…subprocess_runner.time.sleep"``,
# ``"…subprocess_runner.Path.cwd"``.
import os
import subprocess
import threading
import time
from pathlib import Path
from .background_task_executor import start_background_cli_task
from .opensre_cli_runner import (
_INTERACTIVE_OPENSRE_COMMAND_PATHS,
_OPENSRE_BLOCKED_SUBCOMMANDS,
OpensreCommandClass,
OpensreExecutionMode,
OpensreExecutionPlan,
OpensreRunOutcome,
OpensreRunResult,
_build_opensre_execution_plan,
_classify_opensre_command,
_is_interactive_wizard,
_opensre_confirmation_reason,
_run_opensre_foreground,
_run_opensre_foreground_streaming,
build_opensre_cli_argv,
print_interactive_wizard_handoff,
run_opensre_cli_command,
run_opensre_cli_command_result,
)
from .task_streaming import (
_MAX_COMMAND_OUTPUT_CHARS,
_MIN_SUBPROCESS_TERMINAL_WIDTH,
_SYNTHETIC_DIAG_CHARS,
_SYNTHETIC_POLL_SECONDS,
_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS,
_TASK_OUTPUT_PREFIX_WIDTH,
CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS,
SHELL_COMMAND_TIMEOUT_SECONDS,
SYNTHETIC_TEST_TIMEOUT_SECONDS,
_console_file_is_tty,
_join_task_output_streams,
_print_task_output_line,
_pump_task_pty,
_pump_task_stream,
_should_use_pty,
_start_task_output_streams,
_subprocess_env_with_aligned_width,
read_diag,
read_task_output,
terminate_child_process,
)
__all__ = [
"CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS",
"SHELL_COMMAND_TIMEOUT_SECONDS",
"SYNTHETIC_TEST_TIMEOUT_SECONDS",
"OpensreCommandClass",
"OpensreExecutionMode",
"OpensreExecutionPlan",
"OpensreRunOutcome",
"OpensreRunResult",
"Path",
"_INTERACTIVE_OPENSRE_COMMAND_PATHS",
"_MAX_COMMAND_OUTPUT_CHARS",
"_MIN_SUBPROCESS_TERMINAL_WIDTH",
"_OPENSRE_BLOCKED_SUBCOMMANDS",
"_SYNTHETIC_DIAG_CHARS",
"_SYNTHETIC_POLL_SECONDS",
"_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS",
"_TASK_OUTPUT_PREFIX_WIDTH",
"_classify_opensre_command",
"_build_opensre_execution_plan",
"_console_file_is_tty",
"_is_interactive_wizard",
"_join_task_output_streams",
"_opensre_confirmation_reason",
"_print_task_output_line",
"_pump_task_pty",
"_pump_task_stream",
"_run_opensre_foreground",
"_run_opensre_foreground_streaming",
"_should_use_pty",
"_start_task_output_streams",
"_subprocess_env_with_aligned_width",
"build_opensre_cli_argv",
"os",
"print_interactive_wizard_handoff",
"read_diag",
"read_task_output",
"run_opensre_cli_command",
"run_opensre_cli_command_result",
"start_background_cli_task",
"subprocess",
"terminate_child_process",
"threading",
"time",
]
@@ -0,0 +1,252 @@
"""Background CLI task launcher — runs subprocesses with streamed output above the prompt."""
from __future__ import annotations
import contextlib
import os
import subprocess
import tempfile
import threading
import time
from typing import Any
from rich.console import Console
from rich.markup import escape
from surfaces.interactive_shell.runtime import Session, TaskKind, TaskRecord
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
from surfaces.interactive_shell.utils.telemetry import PromptRecorder
from .task_streaming import (
_MAX_COMMAND_OUTPUT_CHARS,
_SYNTHETIC_DIAG_CHARS,
_SYNTHETIC_POLL_SECONDS,
SHELL_COMMAND_TIMEOUT_SECONDS,
_join_task_output_streams,
_pump_task_pty,
_should_use_pty,
_sr_resolve,
_start_task_output_streams,
_subprocess_env_with_aligned_width,
read_diag,
read_task_output,
terminate_child_process,
)
def _compose_task_log_response(
*,
headline: str,
stdout_buf: tempfile.SpooledTemporaryFile[bytes] | None, # type: ignore[type-arg]
stderr_buf: tempfile.SpooledTemporaryFile[bytes], # type: ignore[type-arg]
) -> str:
"""Build the prompt-log response text for a finished background task.
Combines a status headline with the captured stdout and stderr so the
flushed ``background_task`` event carries the real command output (the
error text the user sees in the terminal), not an empty assistant reply.
"""
parts = [headline]
stdout_text = read_task_output(stdout_buf, limit=_MAX_COMMAND_OUTPUT_CHARS)
stderr_text = read_task_output(stderr_buf, limit=_MAX_COMMAND_OUTPUT_CHARS)
if stdout_text:
parts.append(stdout_text)
if stderr_text:
parts.append(stderr_text)
return "\n".join(parts)
def start_background_cli_task(
*,
display_command: str,
argv_list: list[str],
session: Session,
console: Console,
timeout_seconds: int = SHELL_COMMAND_TIMEOUT_SECONDS,
kind: TaskKind = TaskKind.CLI_COMMAND,
use_pty: bool = False,
) -> TaskRecord | None:
"""Start a subprocess as a REPL task while streaming output above the prompt."""
console.print(f"[bold]$ {display_command}[/bold]")
task = session.task_registry.create(kind, command=display_command)
task.mark_running()
# Created at launch so the flushed prompt-log latency spans the full task
# duration; the watcher sets the response and flushes once the outcome
# (including any error text) is known. See for_background_task() docstring.
recorder = PromptRecorder.for_background_task(
session=session, command=display_command, task_id=task.task_id
)
stderr_buf: tempfile.SpooledTemporaryFile[bytes] = tempfile.SpooledTemporaryFile( # type: ignore[type-arg]
max_size=_SYNTHETIC_DIAG_CHARS * 2
)
pty_fds: tuple[int, int] | None = None
if _should_use_pty(console, use_pty):
try:
pty_fds = os.openpty()
except OSError:
pty_fds = None
stdout_buf: tempfile.SpooledTemporaryFile[bytes] | None = None # type: ignore[type-arg]
if pty_fds is None:
stdout_buf = tempfile.SpooledTemporaryFile( # type: ignore[type-arg]
max_size=_MAX_COMMAND_OUTPUT_CHARS
)
subprocess_env = _subprocess_env_with_aligned_width(console)
proc: subprocess.Popen[Any]
try:
if pty_fds is None:
proc = subprocess.Popen(
argv_list,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
start_new_session=True,
env=subprocess_env,
)
else:
_master_fd, slave_fd = pty_fds
proc = subprocess.Popen(
argv_list,
stdin=subprocess.DEVNULL,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
start_new_session=True,
env=subprocess_env,
)
except Exception as exc: # noqa: BLE001
if pty_fds is not None:
for fd in pty_fds:
with contextlib.suppress(OSError):
os.close(fd)
task.mark_failed(str(exc))
if recorder is not None:
with contextlib.suppress(Exception):
recorder.set_error("spawn_failed", str(exc))
recorder.set_response(
_compose_task_log_response(
headline=f"command failed to start: {exc}",
stdout_buf=stdout_buf,
stderr_buf=stderr_buf,
)
)
recorder.flush()
if stdout_buf is not None:
stdout_buf.close()
stderr_buf.close()
report_exception(exc, context="surfaces.interactive_shell.background_cli_task.start")
console.print(f"[{ERROR}]failed to start:[/] {escape(str(exc))}")
return None
task.attach_process(proc)
started_at = time.monotonic()
if pty_fds is None:
output_threads = _start_task_output_streams(
task=task,
proc=proc,
console=console,
stdout_capture=stdout_buf,
stderr_capture=stderr_buf,
)
else:
master_fd, slave_fd = pty_fds
with contextlib.suppress(OSError):
os.close(slave_fd)
output_thread = threading.Thread(
target=_pump_task_pty,
kwargs={"master_fd": master_fd, "console": console, "capture": stderr_buf},
daemon=True,
name=f"task-terminal-{task.task_id}",
)
output_thread.start()
output_threads = [output_thread]
history_gen_when_watch_started = session.terminal.history_generation
def _watch() -> None:
terminated_by_watcher = False
timed_out = False
suggest_follow_up = False
outcome_headline = "command completed (exit 0)"
outcome_error_kind = ""
while proc.poll() is None:
if time.monotonic() - started_at > timeout_seconds:
timed_out = True
task.request_cancel()
terminate_child_process(proc)
terminated_by_watcher = True
break
if task.cancel_requested.is_set():
terminate_child_process(proc)
terminated_by_watcher = True
break
time.sleep(_SYNTHETIC_POLL_SECONDS)
try:
if timed_out:
outcome_headline = f"command timed out after {timeout_seconds} seconds"
outcome_error_kind = "timeout"
task.mark_failed(f"timed out after {timeout_seconds}s")
suggest_follow_up = kind is TaskKind.SYNTHETIC_TEST
return
if terminated_by_watcher and task.cancel_requested.is_set():
outcome_headline = "command cancelled"
task.mark_cancelled()
return
_join_task_output_streams(output_threads)
code = proc.returncode
if code == 0:
task.mark_completed()
else:
diag = _sr_resolve("read_diag", read_diag)(stderr_buf)
error_msg = f"exit code {code}" + (f": {diag}" if diag else "")
outcome_headline = f"command failed (exit {code})"
outcome_error_kind = "cli_exit_nonzero"
task.mark_failed(error_msg)
console.print(f"[{ERROR}]command failed (exit {code}):[/]")
suggest_follow_up = kind is TaskKind.SYNTHETIC_TEST
except Exception as exc: # noqa: BLE001
outcome_headline = f"command error: {exc}"
outcome_error_kind = "watcher_error"
task.mark_failed(str(exc))
report_exception(exc, context="surfaces.interactive_shell.background_cli_task.watch")
console.print(f"[{ERROR}]error:[/] {escape(str(exc))}")
suggest_follow_up = kind is TaskKind.SYNTHETIC_TEST
finally:
_join_task_output_streams(output_threads)
# Flush the prompt-log/PostHog event with the real outcome (stdout,
# stderr, exit/timeout/cancel) before the capture buffers are closed.
if recorder is not None:
with contextlib.suppress(Exception):
if outcome_error_kind:
recorder.set_error(outcome_error_kind, outcome_headline)
recorder.set_response(
_compose_task_log_response(
headline=outcome_headline,
stdout_buf=stdout_buf,
stderr_buf=stderr_buf,
)
)
recorder.flush()
if stdout_buf is not None:
stdout_buf.close()
stderr_buf.close()
if (
suggest_follow_up
and session.terminal.history_generation == history_gen_when_watch_started
):
session.suggest_synthetic_failure_follow_up(label=display_command)
else:
session.terminal.notify_prompt_changed()
thread = threading.Thread(target=_watch, daemon=True)
thread.start()
console.print(
f"[{DIM}]started — task[/] [bold]{escape(task.task_id)}[/bold]. "
f"[{HIGHLIGHT}]/tasks[/] [{DIM}]to monitor,[/] "
f"[{HIGHLIGHT}]/cancel {escape(task.task_id)}[/] [{DIM}]to stop.[/]"
)
return task
@@ -0,0 +1,151 @@
"""OpenSRE CLI command runner — surface adapter over tools.interactive_shell.cli."""
from __future__ import annotations
from collections.abc import Callable
from rich.console import Console
from surfaces.interactive_shell.runtime.subprocess_runner.repl_presenter import make_repl_presenter
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui import DIM, WARNING
from tools.interactive_shell.cli import (
INTERACTIVE_OPENSRE_COMMAND_PATHS,
OPENSRE_BLOCKED_SUBCOMMANDS,
OpensreCommandClass,
OpensreExecutionMode,
OpensreExecutionPlan,
OpensreRunOutcome,
OpensreRunResult,
_run_foreground_via_presenter,
_run_streaming_via_presenter,
build_opensre_cli_argv,
build_opensre_execution_plan,
classify_opensre_command,
interactive_wizard_handoff_response_text,
is_interactive_wizard,
opensre_confirmation_reason,
)
from tools.interactive_shell.cli import (
run_opensre_cli_command as _run_opensre_cli_command,
)
from tools.interactive_shell.cli import (
run_opensre_cli_command_result as _run_opensre_cli_command_result,
)
# Backward-compatible aliases for tests and slash parity.
_INTERACTIVE_OPENSRE_COMMAND_PATHS = INTERACTIVE_OPENSRE_COMMAND_PATHS
_OPENSRE_BLOCKED_SUBCOMMANDS = OPENSRE_BLOCKED_SUBCOMMANDS
def _is_interactive_wizard(tokens: list[str]) -> bool:
return is_interactive_wizard(tokens)
def _classify_opensre_command(tokens: list[str]) -> str:
return classify_opensre_command(tokens)
def _opensre_confirmation_reason(tokens: list[str]) -> str:
return opensre_confirmation_reason(tokens)
def _build_opensre_execution_plan(tokens: list[str]) -> OpensreExecutionPlan:
return build_opensre_execution_plan(tokens)
def print_interactive_wizard_handoff(console: Console, command_str: str) -> None:
console.print(
f"[{WARNING}]`opensre {command_str}` is an interactive wizard "
"that needs a full terminal.[/]"
)
console.print(
f"[{DIM}]Type [bold]/{command_str}[/bold] directly in this shell to launch it.[/]"
)
def run_opensre_cli_command(
args: str,
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
) -> bool:
presenter = make_repl_presenter(
session,
console,
confirm_fn=confirm_fn,
is_tty=is_tty,
action_already_listed=True,
)
return _run_opensre_cli_command(args, presenter)
def run_opensre_cli_command_result(
args: str,
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
) -> OpensreRunResult:
presenter = make_repl_presenter(
session,
console,
confirm_fn=confirm_fn,
is_tty=is_tty,
action_already_listed=True,
)
return _run_opensre_cli_command_result(args, presenter)
# Foreground helpers kept for monkeypatch tests that patch subprocess_runner paths.
def _run_opensre_foreground(
argv_list: list[str],
display_command: str,
session: Session,
console: Console,
) -> None:
presenter = make_repl_presenter(session, console, action_already_listed=True)
_run_foreground_via_presenter(
presenter,
argv_list=argv_list,
display_command=display_command,
)
def _run_opensre_foreground_streaming(
argv_list: list[str],
display_command: str,
session: Session,
console: Console,
) -> None:
presenter = make_repl_presenter(session, console, action_already_listed=True)
_run_streaming_via_presenter(
presenter,
argv_list=argv_list,
display_command=display_command,
)
__all__ = [
"OpensreCommandClass",
"OpensreExecutionMode",
"OpensreExecutionPlan",
"OpensreRunOutcome",
"OpensreRunResult",
"_INTERACTIVE_OPENSRE_COMMAND_PATHS",
"_OPENSRE_BLOCKED_SUBCOMMANDS",
"_build_opensre_execution_plan",
"_classify_opensre_command",
"_is_interactive_wizard",
"_opensre_confirmation_reason",
"_run_opensre_foreground",
"_run_opensre_foreground_streaming",
"build_opensre_cli_argv",
"interactive_wizard_handoff_response_text",
"print_interactive_wizard_handoff",
"run_opensre_cli_command",
"run_opensre_cli_command_result",
]
@@ -0,0 +1,226 @@
"""REPL subprocess presenter — Rich UI + session hooks for action tools."""
from __future__ import annotations
import re
import subprocess
import tempfile
import threading
from collections.abc import Callable
from typing import Any
from rich.console import Console
from rich.markup import escape
from rich.text import Text
from platform.common.task_types import TaskKind
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING, print_command_output
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
from tools.interactive_shell.shared import ExecutionPolicyResult
from tools.interactive_shell.subprocess import SubprocessPresenter, subprocess_env_with_width
from .background_task_executor import (
start_background_cli_task as _start_background_cli_task_default,
)
from .task_streaming import (
_join_task_output_streams,
_sr_resolve,
_start_task_output_streams,
)
_MARKUP_STYLE_ALIASES: dict[str, str] = {
"error": str(ERROR),
"dim": str(DIM),
"highlight": str(HIGHLIGHT),
"warning": str(WARNING),
}
# Intentional Rich markup tags used by subprocess presenters and action tools.
_ALLOWED_MARKUP_TAG = re.compile(
r"(\["
r"(?:"
r"#[0-9A-Fa-f]{6}|"
r"bold|dim|highlight|warning|error|"
r"/(?:bold|dim|highlight|warning|error)?"
r")"
r"\])"
)
_MARKUP_HINT = re.compile(r"\[(?:/?(?:error|dim|highlight|warning|bold)|/)\]")
def _expand_markup_aliases(message: str) -> str:
for alias, token in _MARKUP_STYLE_ALIASES.items():
message = message.replace(f"[{alias}]", f"[{token}]")
message = message.replace(f"[/{alias}]", f"[/{token}]")
return message
def _message_uses_intentional_markup(message: str) -> bool:
if _MARKUP_HINT.search(message):
return True
return any(
f"[{token}]" in message or f"[/{token}]" in message
for token in _MARKUP_STYLE_ALIASES.values()
)
def _escape_markup_message(message: str) -> str:
"""Escape plain-text segments while preserving intentional Rich markup tags."""
expanded = _expand_markup_aliases(message)
if not _message_uses_intentional_markup(expanded):
return escape(expanded)
parts = _ALLOWED_MARKUP_TAG.split(expanded)
if len(parts) == 1:
return escape(expanded)
rendered: list[str] = []
for index, part in enumerate(parts):
if index % 2 == 1:
rendered.append(part)
elif part:
rendered.append(escape(part))
return "".join(rendered)
class ReplSubprocessPresenter:
"""Surface implementation of :class:`SubprocessPresenter` for the interactive REPL."""
def __init__(
self,
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
action_already_listed: bool = False,
) -> None:
self._session = session
self._console = console
self._confirm_fn = confirm_fn
self._is_tty = is_tty
self._action_already_listed = action_already_listed
@property
def session(self) -> Session:
return self._session
@property
def console(self) -> Console:
return self._console
def execution_allowed(
self,
policy: ExecutionPolicyResult,
*,
action_summary: str,
) -> bool:
return execution_allowed(
policy,
session=self._session,
console=self._console,
action_summary=action_summary,
confirm_fn=self._confirm_fn,
is_tty=self._is_tty,
action_already_listed=self._action_already_listed,
)
def print(self, message: str = "") -> None:
self._console.print(_escape_markup_message(message))
def print_bold_command(self, display_command: str) -> None:
self._console.print(f"[bold]$ {escape(display_command)}[/bold]")
def print_command_output(self, text: str, *, style: str | None = None) -> None:
resolved: str | None
if style in _MARKUP_STYLE_ALIASES:
resolved = _MARKUP_STYLE_ALIASES[style]
elif style is None:
resolved = None
else:
resolved = style
print_command_output(self._console, text, style=resolved)
def print_plain(self, text: str) -> None:
self._console.print(Text(text))
def report_exception(self, exc: BaseException, *, context: str) -> None:
report_exception(exc, context=context)
def subprocess_env(self) -> dict[str, str]:
return subprocess_env_with_width(
columns=self._console.size.width or 80,
lines=self._console.size.height,
)
def start_task_output_streams(
self,
*,
task: Any,
proc: subprocess.Popen[Any],
stdout_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
stderr_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
) -> list[threading.Thread]:
return _start_task_output_streams(
task=task,
proc=proc,
console=self._console,
stdout_capture=stdout_capture,
stderr_capture=stderr_capture,
)
def join_task_output_streams(self, threads: list[threading.Thread]) -> None:
_join_task_output_streams(threads)
def start_background_cli_task(
self,
*,
display_command: str,
argv_list: list[str],
timeout_seconds: int,
kind: TaskKind = TaskKind.CLI_COMMAND,
use_pty: bool = False,
) -> Any:
starter = _sr_resolve("start_background_cli_task", _start_background_cli_task_default)
return starter(
display_command=display_command,
argv_list=argv_list,
session=self._session,
console=self._console,
timeout_seconds=timeout_seconds,
kind=kind,
use_pty=use_pty,
)
def print_error(self, message: str) -> None:
self._console.print(f"[{ERROR}]{escape(message)}[/]")
def print_dim(self, message: str) -> None:
self._console.print(f"[{DIM}]{escape(message)}[/]")
def print_highlight(self, message: str) -> None:
self._console.print(f"[{HIGHLIGHT}]{escape(message)}[/]")
def print_warning(self, message: str) -> None:
self._console.print(f"[{WARNING}]{escape(message)}[/]")
def make_repl_presenter(
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
action_already_listed: bool = False,
) -> SubprocessPresenter:
"""Construct a :class:`ReplSubprocessPresenter` for runners and tests."""
return ReplSubprocessPresenter(
session,
console,
confirm_fn=confirm_fn,
is_tty=is_tty,
action_already_listed=action_already_listed,
)
__all__ = ["ReplSubprocessPresenter", "make_repl_presenter"]
@@ -0,0 +1,202 @@
"""Shared subprocess-streaming primitives, PTY helpers, and module-wide constants."""
from __future__ import annotations
import contextlib
import errno
import os
import subprocess
import sys
import tempfile
import threading
from typing import IO, Any
from rich.console import Console
from rich.markup import escape
from rich.text import Text
from platform.common.task_types import TaskRecord
from surfaces.interactive_shell.ui import DIM, ERROR
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
from tools.interactive_shell.subprocess import (
CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS,
MAX_COMMAND_OUTPUT_CHARS,
MIN_SUBPROCESS_TERMINAL_WIDTH,
SHELL_COMMAND_TIMEOUT_SECONDS,
SYNTHETIC_DIAG_CHARS,
SYNTHETIC_POLL_SECONDS,
SYNTHETIC_TEST_TIMEOUT_SECONDS,
TASK_OUTPUT_JOIN_TIMEOUT_SECONDS,
TASK_OUTPUT_PREFIX_WIDTH,
read_diag,
read_task_output,
subprocess_env_with_width,
terminate_child_process,
)
# Full dotted name of the ``subprocess_runner`` package. Submodules use this to
# look up patchable names from the parent namespace at call time so that tests
# using ``monkeypatch.setattr("…subprocess_runner.X", fake)`` take effect even
# when the implementation lives in a submodule.
_SUBPROCESS_RUNNER_MODULE = "surfaces.interactive_shell.runtime.subprocess_runner"
# Backward-compatible aliases for tests and callers using underscore-prefixed names.
_MAX_COMMAND_OUTPUT_CHARS = MAX_COMMAND_OUTPUT_CHARS
_SYNTHETIC_POLL_SECONDS = SYNTHETIC_POLL_SECONDS
_SYNTHETIC_DIAG_CHARS = SYNTHETIC_DIAG_CHARS
_MIN_SUBPROCESS_TERMINAL_WIDTH = MIN_SUBPROCESS_TERMINAL_WIDTH
_TASK_OUTPUT_PREFIX_WIDTH = TASK_OUTPUT_PREFIX_WIDTH
_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS = TASK_OUTPUT_JOIN_TIMEOUT_SECONDS
def _sr_resolve(name: str, default: Any) -> Any:
"""Return ``subprocess_runner.<name>`` if the package is loaded, else ``default``.
Used by submodules to honour monkeypatches applied to the parent package
namespace (e.g. ``monkeypatch.setattr("…subprocess_runner.read_diag", …)``).
"""
sr = sys.modules.get(_SUBPROCESS_RUNNER_MODULE)
return getattr(sr, name, default) if sr is not None else default
def _print_task_output_line(
console: Console,
task: TaskRecord,
stream_name: str,
line: str,
*,
style: str | None = None,
) -> None:
text = Text()
text.append(f"{task.task_id} {stream_name}", style=DIM)
text.append(line.rstrip("\r\n"), style=style)
console.print(text)
def _subprocess_env_with_aligned_width(console: Console) -> dict[str, str]:
"""Return ``os.environ`` patched so a piped Rich subprocess wraps to fit."""
user_width = console.size.width or _MIN_SUBPROCESS_TERMINAL_WIDTH + _TASK_OUTPUT_PREFIX_WIDTH
return subprocess_env_with_width(
columns=user_width,
lines=console.size.height,
)
def _pump_task_stream(
*,
task: TaskRecord,
stream_name: str,
stream: IO[str],
console: Console,
style: str | None = None,
capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
) -> None:
try:
for line in stream:
if capture is not None:
capture.write(line.encode("utf-8", errors="replace"))
if line.strip():
_print_task_output_line(console, task, stream_name, line, style=style)
task.update_progress(line)
except Exception as exc: # noqa: BLE001
report_exception(exc, context=f"surfaces.interactive_shell.task_stream.{stream_name}")
console.print(f"[{DIM}]task output stream ended unexpectedly:[/] {escape(str(exc))}")
def _start_task_output_streams(
*,
task: TaskRecord,
proc: subprocess.Popen[Any],
console: Console,
stdout_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
stderr_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
) -> list[threading.Thread]:
threads: list[threading.Thread] = []
streams: tuple[tuple[str, IO[str] | None, str | None, Any], ...] = (
("stdout", proc.stdout, None, stdout_capture),
("stderr", proc.stderr, ERROR, stderr_capture),
)
for stream_name, stream, style, capture in streams:
if stream is None:
continue
thread = threading.Thread(
target=_pump_task_stream,
kwargs={
"task": task,
"stream_name": stream_name,
"stream": stream,
"console": console,
"style": style,
"capture": capture,
},
daemon=True,
name=f"task-output-{task.task_id}-{stream_name}",
)
thread.start()
threads.append(thread)
return threads
def _join_task_output_streams(threads: list[threading.Thread]) -> None:
for thread in threads:
thread.join(timeout=TASK_OUTPUT_JOIN_TIMEOUT_SECONDS)
def _console_file_is_tty(console: Console) -> bool:
isatty = getattr(console.file, "isatty", None)
return bool(isatty and isatty())
def _should_use_pty(console: Console, requested: bool) -> bool:
return requested and hasattr(os, "openpty") and _console_file_is_tty(console)
def _pump_task_pty(
*,
master_fd: int,
console: Console,
capture: tempfile.SpooledTemporaryFile[bytes], # type: ignore[type-arg]
) -> None:
try:
while True:
try:
chunk = os.read(master_fd, 4096)
except OSError as exc:
if exc.errno == errno.EIO:
break
raise
if not chunk:
break
capture.write(chunk)
console.file.write(chunk.decode("utf-8", errors="replace"))
console.file.flush()
except Exception as exc: # noqa: BLE001
report_exception(exc, context="surfaces.interactive_shell.task_pty_stream")
console.print(f"[{DIM}]task terminal stream ended unexpectedly:[/] {escape(str(exc))}")
finally:
with contextlib.suppress(OSError):
os.close(master_fd)
__all__ = [
"SHELL_COMMAND_TIMEOUT_SECONDS",
"SYNTHETIC_TEST_TIMEOUT_SECONDS",
"CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS",
"_SYNTHETIC_POLL_SECONDS",
"_MAX_COMMAND_OUTPUT_CHARS",
"_SYNTHETIC_DIAG_CHARS",
"_TASK_OUTPUT_PREFIX_WIDTH",
"_MIN_SUBPROCESS_TERMINAL_WIDTH",
"_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS",
"terminate_child_process",
"read_diag",
"read_task_output",
"_print_task_output_line",
"_subprocess_env_with_aligned_width",
"_pump_task_stream",
"_start_task_output_streams",
"_join_task_output_streams",
"_console_file_is_tty",
"_should_use_pty",
"_pump_task_pty",
]
@@ -0,0 +1,250 @@
"""Runtime turn host for submitted interactive-shell prompts.
Three public runtime functions live here:
- ``run_agent_turn`` — set up shell presentation for one submitted turn and drive
its lifecycle (the injected ``run_turn`` callable for the queue).
- ``run_input_loop`` — read prompt input events and dispatch them until exit.
- ``run_agent_turn_queue`` — consume queued turns and run each one until exit.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import threading
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from rich.console import Console
from platform.analytics.repl_context import bound_repl_turn_context
from platform.observability.trace.spans import bind_session_trace, emit_thread_boundary
from surfaces.interactive_shell.runtime.agent_presentation import (
AgentEvent,
AgentEventSink,
ConsoleAgentEventSink,
)
from surfaces.interactive_shell.runtime.background.workers import BackgroundTaskManager
from surfaces.interactive_shell.runtime.core.confirmation import (
DispatchCancelled,
request_confirmation_via_prompt,
)
from surfaces.interactive_shell.runtime.core.state import ReplState, SpinnerState
from surfaces.interactive_shell.runtime.input import PromptInputReader
from surfaces.interactive_shell.runtime.input.actions import (
InputAction,
ShellInputSnapshot,
decide_input_action,
)
from surfaces.interactive_shell.runtime.utils.input_policy import (
turn_needs_exclusive_stdin,
)
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui.output.console_state import set_investigation_spinner
from surfaces.interactive_shell.ui.output.repl_progress import repl_safe_progress_scope
from surfaces.interactive_shell.ui.streaming.console import StreamingConsole
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
from surfaces.interactive_shell.utils.telemetry import PromptRecorder
_logger = logging.getLogger(__name__)
_AGENT_TURN_KIND = "agent"
@dataclass(frozen=True)
class AgentTurnRuntime:
"""Immutable dependencies for running one submitted shell turn."""
session: Session
state: ReplState
spinner: SpinnerState
invalidate_prompt: Callable[[], None]
request_exit: Callable[[], None] | None = None
async def run_agent_turn(runtime: AgentTurnRuntime, text: str) -> None:
"""Set up shell presentation for one turn and drive its lifecycle."""
dispatch_cancel = threading.Event()
console = StreamingConsole(
runtime.spinner,
dispatch_cancel,
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
emit = ConsoleAgentEventSink(
session=runtime.session,
spinner=runtime.spinner,
console=console,
)
recorder = PromptRecorder.start(
session=runtime.session,
text=text,
turn_kind=_AGENT_TURN_KIND,
)
exclusive_stdin = turn_needs_exclusive_stdin(text, runtime.session)
progress_scope = contextlib.nullcontext() if exclusive_stdin else repl_safe_progress_scope()
runtime.session.terminal.exclusive_stdin_active = exclusive_stdin
# Expose this turn's spinner so investigation stages can animate phase labels.
set_investigation_spinner(runtime.spinner)
emit_thread_boundary(
runtime.session.session_id,
name="turn_boundary",
phase="turn_start",
)
try:
with (
bind_session_trace(runtime.session.session_id),
progress_scope,
):
await _run_agent_turn_loop(
runtime=runtime,
text=text,
output=console,
recorder=recorder,
confirm=lambda prompt: request_confirmation_via_prompt(runtime.state, prompt),
emit=emit,
dispatch_cancel=dispatch_cancel,
)
finally:
set_investigation_spinner(None)
runtime.session.terminal.exclusive_stdin_active = False
emit_thread_boundary(
runtime.session.session_id,
name="turn_boundary",
phase="turn_end",
)
async def _run_agent_turn_loop(
*,
runtime: AgentTurnRuntime,
text: str,
output: StreamingConsole,
recorder: PromptRecorder | None,
confirm: Callable[[str], str],
emit: AgentEventSink,
dispatch_cancel: threading.Event,
) -> None:
current_task = asyncio.current_task()
if current_task is not None:
runtime.state.start_dispatch(task=current_task, cancel_event=dispatch_cancel)
else:
runtime.state.attach_cancel_event(dispatch_cancel)
await emit(AgentEvent(type="turn_start", text=text))
try:
# Imported lazily so constructing the controller (and importing this
# module) does not pull the harness/turn-execution stack
# (``action_agent -> core.agent``) before the first turn is queued.
from surfaces.interactive_shell.runtime.shell_turn_execution import execute_shell_turn
with bound_repl_turn_context(
session_id=runtime.session.session_id,
turn_kind=_AGENT_TURN_KIND,
prompt_turn_id=recorder.turn_id if recorder is not None else None,
):
await asyncio.to_thread(
execute_shell_turn,
text,
runtime.session,
output,
recorder=recorder,
confirm_fn=confirm,
is_tty=None,
request_exit=runtime.request_exit,
)
except asyncio.CancelledError:
await emit(AgentEvent(type="turn_interrupted"))
raise
except DispatchCancelled:
await emit(AgentEvent(type="turn_interrupted"))
except Exception as exc:
report_exception(exc, context="surfaces.interactive_shell.turn")
await emit(AgentEvent(type="turn_error", error=exc))
finally:
runtime.state.finish_dispatch(dispatch_cancel)
await emit(AgentEvent(type="turn_end"))
async def run_input_loop(
*,
state: ReplState,
session: Session,
background: BackgroundTaskManager | None,
input_reader: PromptInputReader,
echo_console: Console,
handle_input_action: Callable[[InputAction], Awaitable[bool]],
) -> None:
"""Run the interactive session's main input loop until exit or close.
This loop reads input; it does not run agent turns itself. Each raw input
event is classified into an ``InputAction`` by ``decide_input_action`` and
handed to ``handle_input_action``. For a submitted prompt that handler pushes
the text onto ``state.queue``; the queued text is then consumed
asynchronously by ``run_agent_turn_queue`` (started in the controller's
``_start_runtime_services``), which runs each turn via ``run_agent_turn``.
Keeping input reading and turn execution as two separate loops joined only by
``state.queue`` is deliberate: it lets the user keep typing, cancel, or
answer a confirmation while a turn is still in flight.
"""
while not state.exit_requested:
if background is not None:
background.drain_turn_start_output(echo_console)
event = await input_reader.read()
action = decide_input_action(
event,
ShellInputSnapshot(
exit_requested=state.exit_requested,
dispatch_running=state.is_dispatch_running(),
awaiting_confirmation=state.is_awaiting_confirmation(),
),
needs_exclusive_stdin=lambda text: turn_needs_exclusive_stdin(
text,
session,
),
)
should_continue = await handle_input_action(action)
if not should_continue:
return
async def run_agent_turn_queue(
*,
state: ReplState,
run_turn: Callable[[str], Coroutine[Any, Any, None]],
) -> None:
"""Consume queued turns and run each one until exit."""
while not state.exit_requested:
try:
text = await state.queue.get()
except asyncio.CancelledError:
return
if state.exit_requested:
state.queue.task_done()
return
turn_task = asyncio.create_task(run_turn(text))
state.attach_turn_task(turn_task)
try:
await turn_task
except asyncio.CancelledError:
_logger.debug("Queued turn task was cancelled")
except Exception as exc:
_logger.debug("Queued turn task ended with exception: %s", exc)
finally:
state.clear_current_task()
state.queue.task_done()
__all__ = [
"AgentTurnRuntime",
"run_agent_turn",
"run_agent_turn_queue",
"run_input_loop",
]
@@ -0,0 +1,102 @@
"""Injection contracts for the interactive-shell turn seams.
These protocols describe exactly what ``execute_shell_turn`` requires from the
action / gather / answer adapters it composes, so an injected test double is
checked at type-time rather than at runtime. The default adapters
(``action_turn``, ``answer_turn``, ``integration_tool_gathering``) satisfy them.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Protocol, TypedDict
from rich.console import Console
from core.agent_harness.ports import OutputSink
from core.agent_harness.turns.turn_plan import TurnPlan
from core.agent_harness.turns.turn_results import ToolCallingTurnResult
from core.execution import ToolExecutionHooks
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.utils.telemetry import LlmRunInfo
class RunActionToolTurn(Protocol):
"""Action-selection seam driven by ``execute_shell_turn``.
``deps`` is intentionally not part of the contract: ``execute_shell_turn``
never injects it, and the default adapter supplies its own LLM factory.
"""
def __call__(
self,
message: str,
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
request_exit: Callable[[], None] | None = None,
turn_plan: TurnPlan | None = None,
output: OutputSink | None = None,
tool_hooks: ToolExecutionHooks | None = None,
) -> ToolCallingTurnResult:
"""Run one action turn and return its facts."""
class GatherEvidence(Protocol):
"""Gather seam: collect read-only integration evidence, or None."""
def __call__(
self,
message: str,
session: Session,
console: Console,
*,
is_tty: bool | None = None,
resolved_integrations: dict[str, Any] | None = None,
) -> str | None:
"""Gather evidence for the message, or return None when nothing applies."""
class AnswerKwargs(TypedDict, total=False):
"""Keyword args ``run_turn`` forwards to the answer seam (all optional).
``total=False`` mirrors ``run_turn`` omitting ``tool_observation_on_screen``
on the plain (no-evidence) path.
"""
confirm_fn: Callable[[str], str] | None
is_tty: bool | None
tool_observation: str | None
tool_observation_on_screen: bool
handoff_contents: tuple[str, ...]
turn_plan: TurnPlan | None
class AnswerShellQuestion(Protocol):
"""Answer seam: respond via the grounded conversational assistant."""
def __call__(
self,
message: str,
session: Session,
console: Console,
*,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
tool_observation: str | None = None,
tool_observation_on_screen: bool = True,
handoff_contents: tuple[str, ...] = (),
turn_plan: TurnPlan | None = None,
output: OutputSink | None = None,
) -> LlmRunInfo | None:
"""Answer the question, returning the LLM run info or None."""
__all__ = [
"AnswerKwargs",
"AnswerShellQuestion",
"GatherEvidence",
"RunActionToolTurn",
]
@@ -0,0 +1 @@
"""Runtime utility helpers for interactive shell orchestration."""
@@ -0,0 +1,120 @@
"""Prompt input and stdin coordination policy for runtime turns."""
from __future__ import annotations
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui.components.choice_menu import repl_tty_interactive
def _literal_slash_command_text(text: str) -> str | None:
"""Return literal ``/slash`` command text for command-shaped input, else ``None``.
Terminal-UI policy only (spinner suppression and exclusive-stdin gating). The
matching execution-side deterministic dispatch lives separately in
``core/agent_harness/turns/action_driver.py``; keep this function UI-only and do not
grow natural-language intent inference here.
"""
stripped = text.strip()
return stripped if stripped.startswith("/") else None
_EXCLUSIVE_STDIN_MENU_COMMANDS: frozenset[str] = frozenset(
{
"/history",
"/auth",
"/help",
"/integrations",
"/investigate",
"/mcp",
"/model",
"/tools",
"/template",
"/trust",
"/verbose",
"/?",
# Table-outputting commands must complete before the next prompt_async()
# starts, otherwise patch_stdout redraws trigger ESC[6n DSR queries whose
# CPR responses land as literal keystrokes in the incoming prompt buffer.
"/doctor",
"/version",
"/verify",
"/status",
"/cost",
"/tasks",
"/watches",
"/alerts",
"/privacy",
"/context",
"/fleet",
"/compact",
"/welcome",
"/sessions",
"/resume",
"/new",
"/rca",
}
)
_EXCLUSIVE_STDIN_SUBCOMMANDS: frozenset[tuple[str, str]] = frozenset(
{
("/integrations", "setup"),
# ``remove`` drives a native inline arrow-key picker (raw os.read on
# stdin). Without exclusive stdin the concurrent prompt_async() steals
# keystrokes and CPR responses leak into the next prompt buffer.
("/integrations", "remove"),
("/mcp", "connect"),
("/mcp", "disconnect"),
("/rca", "history"),
("/rca", "list"),
("/rca", "ls"),
("/rca", "show"),
("/rca", "save"),
}
)
_WAIT_FOR_COMPLETION_COMMANDS: frozenset[str] = frozenset(
{"/exit", "/quit", "/update", "/onboard", "/config", "/auth", "/login"}
)
def turn_should_show_spinner(text: str, _session: Session) -> bool:
# UI-only: suppress the "thinking" spinner for literal slash commands, which
# dispatch deterministically (no LLM) and would otherwise show a misleading
# spinner. Natural-language turns still go through the action-agent LLM.
return _literal_slash_command_text(text.strip()) is None
def turn_needs_exclusive_stdin(text: str, _session: Session) -> bool:
if not repl_tty_interactive():
return False
t = text.strip()
if not t:
return False
# Reserve stdin early for literal command-shaped input, but do not dispatch
# here. This stays UI-only; deterministic slash execution lives in the turn
# engine (core/agent_harness/turns/action_driver.py), not in this gating layer.
dispatch_text = _literal_slash_command_text(t)
if dispatch_text is None:
return False
parts = dispatch_text.split()
if not parts:
return False
name = parts[0].lower()
args = [arg.lower() for arg in parts[1:]]
if name in _WAIT_FOR_COMPLETION_COMMANDS:
return True
if name == "/theme":
return True
if name in _EXCLUSIVE_STDIN_MENU_COMMANDS and not args:
return True
if name == "/tests" and not args:
return True
return bool(args and (name, args[0]) in _EXCLUSIVE_STDIN_SUBCOMMANDS)
__all__ = [
"turn_needs_exclusive_stdin",
"turn_should_show_spinner",
]
@@ -0,0 +1,34 @@
"""Interactive-shell session: the ``Session`` subclass and its UI facets.
The shell-only half of the session, layered on
:class:`~core.agent_harness.session.session_core.SessionCore`: the ``terminal``
facet (theme, prompt-toolkit, background jobs, metrics) and the ``alerts`` inbox.
Core, gateway, and headless surfaces use ``SessionCore`` directly and never import
this package.
"""
from __future__ import annotations
from surfaces.interactive_shell.session.alert_inbox import SessionAlertInbox
from surfaces.interactive_shell.session.background_investigations import (
BackgroundInvestigationRecord,
BackgroundNotificationPreferences,
)
from surfaces.interactive_shell.session.session import Session
from surfaces.interactive_shell.session.terminal_metrics import (
InterventionKind,
TerminalMetrics,
TerminalMetricsSnapshot,
)
from surfaces.interactive_shell.session.terminal_session import TerminalSession
__all__ = [
"BackgroundInvestigationRecord",
"BackgroundNotificationPreferences",
"InterventionKind",
"Session",
"SessionAlertInbox",
"TerminalMetrics",
"TerminalMetricsSnapshot",
"TerminalSession",
]
@@ -0,0 +1,40 @@
"""The session's inbox of externally-received alerts.
A surface facet composed onto :class:`~surfaces.interactive_shell.session.session.Session`:
the interactive shell's alert listener appends externally-received alerts here, and
``/status`` reads them. Kept out of the core session so consumers that never touch
alerts don't see the field.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from core.domain.alerts.inbox import IncomingAlert
# Bounded buffer so the alert listener can't grow memory unbounded — keeps the last
# few hundred alerts for /status. A round default (not tuned); preserved from the
# original ``_INCOMING_ALERTS_MAX``.
_DEFAULT_MAX = 256
@dataclass
class SessionAlertInbox:
"""Bounded FIFO of received alerts shown in ``/status`` (oldest dropped past the cap)."""
entries: list[IncomingAlert] = field(default_factory=list)
_max: int = _DEFAULT_MAX
def add(self, alert: IncomingAlert) -> None:
"""Append an alert, dropping the oldest once the cap is exceeded."""
self.entries.append(alert)
if len(self.entries) > self._max:
del self.entries[0]
@property
def most_recent(self) -> IncomingAlert | None:
"""The newest alert, or None when the inbox is empty."""
return self.entries[-1] if self.entries else None
def clear(self) -> None:
self.entries.clear()
@@ -0,0 +1,35 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class BackgroundInvestigationRecord:
"""One completed or in-flight background investigation tracked by the REPL."""
task_id: str
status: str
command: str
investigation_id: str = ""
root_cause: str = ""
top_analysis: tuple[str, ...] = ()
next_steps: tuple[str, ...] = ()
stats: dict[str, Any] = field(default_factory=dict)
final_state: dict[str, Any] = field(default_factory=dict)
notification_results: dict[str, str] = field(default_factory=dict)
@dataclass
class BackgroundNotificationPreferences:
"""Session-scoped channel preferences for background RCA completion notifications."""
channels: tuple[str, ...] = ()
def set_channels(self, values: list[str]) -> None:
cleaned: list[str] = []
for value in values:
normalized = value.strip().lower()
if normalized and normalized not in cleaned:
cleaned.append(normalized)
self.channels = tuple(cleaned)
@@ -0,0 +1,125 @@
"""Interactive-shell session: SessionCore plus terminal UI state.
Extends :class:`~core.agent_harness.session.session_core.SessionCore` with the
shell-only facets (``terminal`` UI/background state and the ``alerts`` inbox) and
the methods that drive them.
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass, field
from config.constants.prompts import SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
from core.agent_harness.session.session_core import SessionCore
from core.domain.alerts.inbox import IncomingAlert
from surfaces.interactive_shell.session.alert_inbox import SessionAlertInbox
from surfaces.interactive_shell.session.terminal_session import TerminalSession
_SCENARIO_FLAG_RE = re.compile(r"--scenario\s+(\S+)")
_SYNTHETIC_SCENARIO_ID_RE = re.compile(r"^\d{3}-[a-z0-9][a-z0-9-]*$")
def _scenario_id_from_synthetic_label(label: str) -> str:
"""Extract a scenario id from a synthetic command or ``suite:scenario`` label."""
match = _SCENARIO_FLAG_RE.search(label)
if match is not None:
candidate = match.group(1).strip()
return candidate if _SYNTHETIC_SCENARIO_ID_RE.fullmatch(candidate) else ""
if ":" in label:
candidate = label.rsplit(":", 1)[-1].strip()
return candidate if _SYNTHETIC_SCENARIO_ID_RE.fullmatch(candidate) else ""
return ""
@dataclass
class Session(SessionCore):
"""Per-REPL-process session: :class:`SessionCore` plus interactive-shell state.
Adds the shell-only ``terminal`` facet (UI/theme/prompt-toolkit/background)
and the ``alerts`` inbox on top of the surface-agnostic core.
"""
terminal: TerminalSession = field(default_factory=TerminalSession)
"""Interactive-shell (terminal) session facet — shell-only UI/theme/background state.
Always present (empty for non-shell sessions) so shell code needs no None-guard;
``core``/``gateway``/``tools`` consumers ignore it. Holds the theme, prompt-toolkit,
pending-prompt/stdin, background-jobs, and metrics clusters (#3690)."""
alerts: SessionAlertInbox = field(default_factory=SessionAlertInbox)
"""Inbox of externally-received alerts (shell alert listener → ``/status``).
A surface facet: the bounded alert list + cap live on ``SessionAlertInbox`` so
core-session consumers that never touch alerts don't see the field."""
def suggest_synthetic_failure_follow_up(self, *, label: str = "") -> None:
"""Queue RCA prefill after a failed synthetic run and refresh the active prompt."""
self.terminal.pending_prompt_default = SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
self.terminal.notify_prompt_changed()
self._bind_last_synthetic_observation(_scenario_id_from_synthetic_label(label))
self.terminal.notify_prompt_changed()
def _bind_last_synthetic_observation(self, scenario_id: str) -> None:
"""Point ``last_synthetic_observation_path`` (a core field) at the run's latest.json.
Synthetic-run UX, so it lives on the shell session rather than the core.
"""
if not scenario_id:
self.last_synthetic_observation_path = None
return
# Shared path constant lives in config so core and surfaces stay decoupled.
try:
from config.constants.paths import SYNTHETIC_SCENARIOS_DIR
except Exception:
self.last_synthetic_observation_path = None
return
latest = SYNTHETIC_SCENARIOS_DIR / "_observations" / scenario_id / "latest.json"
for _ in range(8):
if latest.is_file():
self.last_synthetic_observation_path = str(latest.resolve())
return
time.sleep(0.06)
self.last_synthetic_observation_path = None
def record_incoming_alert(self, alert: IncomingAlert) -> None:
"""Append a full IncomingAlert with all metadata to session history.
Also stores the alert in the ``alerts`` inbox facet (bounded FIFO), preserving
received_at, severity, source, and alert_name so /status displays accurate
timestamps and future uses have complete data.
"""
self.history.append({"type": "incoming_alert", "text": alert.text, "ok": True})
self.storage.append_turn(self, "incoming_alert", alert.text)
self.alerts.add(alert)
def clear(self, *, rotate_identity: bool = True) -> None:
"""Reset the session — core state plus the shell facets — for /new and /resume."""
self.terminal.history_generation += 1
super().clear(rotate_identity=rotate_identity)
self.alerts.clear()
self.terminal.metrics.reset()
self.terminal.pending_prompt_default = None
self.terminal.pending_prompt_autosubmit = False
self.terminal.exclusive_stdin_active = False
self.terminal.agent_turn_executed_slashes.clear()
self.terminal.background_mode_enabled = False
self.terminal.background_investigations.clear()
# Preserve notification channel prefs across /new like trust_mode.
# Only reset when the user explicitly changes them via /background notify.
with self.terminal._background_notices_lock:
self.terminal.background_notices.clear()
# trust_mode and reasoning_effort are intentionally preserved across /new
def release_resources(self) -> None:
"""Cancel background work and drop loop-owned UI references for teardown.
Extends :meth:`SessionCore.release_resources` (which cancels the
integration-warm task) with the shell facet's own teardown.
"""
super().release_resources()
with self.terminal._background_notices_lock:
self.terminal.background_notices.clear()
self.terminal.prompt_refresh_fn = None
self.terminal.fleet_sampler_starter = None
@@ -0,0 +1,94 @@
"""Per-session terminal analytics, extracted from the session state object.
Groups the interactive-shell turn/intervention counters into one cohesive
accumulator so the session state class does not carry analytics fields and
methods directly.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from pydantic import ConfigDict
from config.strict_config import StrictConfigModel
InterventionKind = Literal["ctrl_c", "correction"]
class TerminalMetricsSnapshot(StrictConfigModel):
"""Immutable per-turn analytics snapshot returned from ``record_turn``.
Pure value; the mutable :class:`TerminalMetrics` accumulator produces one
of these after each turn for the caller to render/emit.
"""
model_config = ConfigDict(extra="forbid", frozen=True)
turn_index: int
fallback_count: int
action_success_percent: float
fallback_rate_percent: float
@dataclass
class TerminalMetrics:
"""Mutable session-level counters for interactive-shell analytics."""
turn_count: int = 0
fallback_count: int = 0
actions_executed_count: int = 0
actions_success_count: int = 0
ctrl_c_intervention_count: int = 0
"""Incremented when the user Ctrl-Cs an active investigation. Bare-prompt
Ctrl-C with no agent running is intentionally not counted."""
correction_intervention_count: int = 0
"""Incremented when a follow-up/new-alert message starts with a correction cue."""
def record_turn(
self,
*,
executed_count: int,
executed_success_count: int,
fallback_to_llm: bool,
) -> TerminalMetricsSnapshot:
"""Update aggregate terminal metrics and return a stable snapshot."""
self.turn_count += 1
self.actions_executed_count += max(0, executed_count)
self.actions_success_count += max(0, executed_success_count)
if fallback_to_llm:
self.fallback_count += 1
action_success_percent = (
100.0 * self.actions_success_count / self.actions_executed_count
if self.actions_executed_count > 0
else 0.0
)
fallback_rate_percent = 100.0 * self.fallback_count / self.turn_count
return TerminalMetricsSnapshot(
turn_index=self.turn_count,
fallback_count=self.fallback_count,
action_success_percent=action_success_percent,
fallback_rate_percent=fallback_rate_percent,
)
def record_intervention(self, kind: InterventionKind) -> None:
"""Increment the per-kind intervention counter (Ctrl-C or correction)."""
if kind == "ctrl_c":
self.ctrl_c_intervention_count += 1
elif kind == "correction":
self.correction_intervention_count += 1
else:
raise ValueError(f"Unknown intervention kind: {kind!r}")
def reset(self) -> None:
"""Zero all counters (used by ``/new``)."""
self.turn_count = 0
self.fallback_count = 0
self.actions_executed_count = 0
self.actions_success_count = 0
self.ctrl_c_intervention_count = 0
self.correction_intervention_count = 0
__all__ = ["InterventionKind", "TerminalMetrics", "TerminalMetricsSnapshot"]
@@ -0,0 +1,216 @@
"""Interactive-shell (terminal) session facet.
Groups the shell-surface-only session state (prompt-toolkit, theme, background jobs,
metrics, per-turn analytics staging) that ``core``, ``gateway``, and ``tools``
consumers never touch. Composed onto :class:`~surfaces.interactive_shell.session.session.Session`
as ``session.terminal`` and always present (empty for non-shell sessions), so shell
code accesses fields without a None-guard.
Populated cluster-by-cluster as the #3690 split lands; theme is the first cluster.
"""
from __future__ import annotations
import threading
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from surfaces.interactive_shell.session.background_investigations import (
BackgroundInvestigationRecord,
BackgroundNotificationPreferences,
)
from surfaces.interactive_shell.session.terminal_metrics import TerminalMetrics
if TYPE_CHECKING:
from prompt_toolkit.history import History
@dataclass
class TerminalSession:
"""Shell-surface session state, composed onto ``Session`` for the interactive shell."""
active_theme_name: str = "green"
"""Interactive shell palette name for this REPL session (``/theme``, prompts)."""
pending_theme_refresh: bool = False
"""When True, apply the active palette to prompt-toolkit before the next prompt."""
trust_mode: bool = False
"""When True, confirmation prompts for elevated REPL actions are skipped."""
prompt_history_backend: History | None = None
"""The live ``prompt_toolkit.History`` object backing the input prompt.
Stored here so ``/history`` and ``/privacy`` slash commands can mutate its
``paused`` flag (when it is a ``RedactingFileHistory``) without needing access to
the ``PromptSession``."""
prompt_app: Any = None
"""The prompt-toolkit ``Application`` instance for this session.
Stored here (instead of accessed via ``get_app_or_none()``) so that worker-thread
slash commands (e.g. ``/theme``) can refresh styles via ``call_soon_threadsafe`` on
the main asyncio loop."""
main_loop: Any = None
"""The asyncio event loop for the main REPL coroutine.
Set once by ``InteractiveShellController.start_interactive_shell`` so worker-thread
code can schedule prompt-toolkit updates on the main thread."""
prompt_refresh_fn: Callable[[], None] | None = field(default=None, repr=False)
"""Loop-owned hook to apply pending prefill and redraw the active prompt."""
fleet_sampler_starter: Callable[[], None] | None = field(default=None, repr=False)
"""Loop-owned hook to lazily start the fleet sampler on first live ``/fleet`` use.
Set by the interactive-shell controller so the sampler (and its ``psutil`` dependency)
stays out of base REPL startup and only runs when fleet monitoring is actually
requested. Thread-safe: the starter marshals task creation onto the REPL event loop."""
pending_prompt_default: str | None = None
"""When set, the next interactive prompt is pre-filled with this string (then cleared)."""
pending_prompt_autosubmit: bool = False
"""When True alongside ``pending_prompt_default``, the prefilled prompt is
submitted automatically instead of waiting for the user to press Enter.
Used to auto-launch an interactive command the agent decided to run (e.g.
``/integrations setup sentry``) so it flows through the normal
exclusive-stdin dispatch path — the only place an interactive child process
gets clean stdin."""
exclusive_stdin_active: bool = False
"""True while a turn is running with exclusive stdin reserved (no live prompt).
Inline picker/wizard slash commands must dispatch immediately during these
turns instead of re-queueing via ``set_auto_command``, which would loop."""
agent_turn_executed_slashes: set[str] = field(default_factory=set, repr=False)
"""Slash command lines already executed during the current action-agent turn.
Prevents the tool-calling loop from re-dispatching the same literal slash
command when the model emits a duplicate ``slash_invoke`` on a later iteration."""
background_mode_enabled: bool = False
"""Whether new investigations should run as session-local background tasks."""
background_investigations: dict[str, BackgroundInvestigationRecord] = field(
default_factory=dict
)
"""Completed or in-flight background RCA summaries, keyed by task id."""
background_notification_preferences: BackgroundNotificationPreferences = field(
default_factory=BackgroundNotificationPreferences
)
"""Preferred notification channels for background RCA completion events."""
background_notices: list[str] = field(default_factory=list)
"""Thread-safe queue of Rich markup messages drained by the REPL main loop."""
_background_notices_lock: threading.Lock = field(
default_factory=threading.Lock, repr=False, compare=False
)
history_generation: int = 0
"""Incremented on /new so background synthetic watchers can skip stale history writes."""
metrics: TerminalMetrics = field(default_factory=TerminalMetrics)
"""Interactive-shell turn/intervention analytics counters (see ``/status``)."""
_turn_outcome_hint: str | None = field(default=None, repr=False, compare=False)
"""Optional structured outcome set by a terminal handler for analytics."""
_pending_turn_llm: Any | None = field(default=None, repr=False, compare=False)
"""LLM run metadata (an ``LlmRunInfo``) staged by a terminal handler for the
current turn's prompt-recorder flush. Consumed exactly once via
``pop_pending_turn_llm`` so it cannot leak into later turns."""
_pending_turn_error: tuple[str, str] | None = field(default=None, repr=False, compare=False)
"""Structured ``(error_kind, message)`` staged by a failing handler for the
current turn's prompt-recorder flush. Consumed exactly once via
``pop_pending_turn_error`` so it cannot leak into later turns."""
# ── behavior over the fields above (Session delegates via ``session.terminal``) ──
def pop_pending_prompt_default(self) -> str:
"""Return pre-filled text for the next prompt line, if any, and clear it."""
value = self.pending_prompt_default
self.pending_prompt_default = None
return value or ""
def pop_pending_autosubmit(self) -> bool:
"""Return whether the pending prefill should auto-submit, and clear the flag."""
value = self.pending_prompt_autosubmit
self.pending_prompt_autosubmit = False
return value
def set_auto_command(self, command: str) -> None:
"""Queue a command to run automatically on the next prompt iteration.
Prefills the input with ``command`` and marks it for auto-submit, then
refreshes the active prompt so the loop submits it without waiting for
Enter. Lets the agent launch an interactive command (setup/connect)
through the normal exclusive-stdin dispatch path rather than spawning it
mid-turn, where it would fight the live prompt for stdin.
"""
self.pending_prompt_default = command
self.pending_prompt_autosubmit = True
self.notify_prompt_changed()
def notify_prompt_changed(self) -> None:
"""Redraw the active prompt (placeholder state and pending prefill)."""
if self.prompt_refresh_fn is not None:
self.prompt_refresh_fn()
def ensure_fleet_sampler_started(self) -> None:
"""Request that the fleet sampler start (no-op if unwired or already running)."""
if self.fleet_sampler_starter is not None:
self.fleet_sampler_starter()
def enqueue_background_notice(self, message: str) -> None:
"""Queue a background-thread status line for the main REPL loop to print."""
with self._background_notices_lock:
self.background_notices.append(message)
self.notify_prompt_changed()
def drain_background_notices(self) -> list[str]:
"""Return and clear any queued background status lines."""
with self._background_notices_lock:
notices = list(self.background_notices)
self.background_notices.clear()
return notices
def set_turn_outcome_hint(self, hint: str | None) -> None:
"""Attach a structured outcome for the current terminal handler."""
self._turn_outcome_hint = hint.strip() if isinstance(hint, str) and hint.strip() else None
def pop_turn_outcome_hint(self) -> str | None:
"""Return and clear any structured outcome hint for this turn."""
hint = self._turn_outcome_hint
self._turn_outcome_hint = None
return hint
def set_pending_turn_llm(self, run: Any | None) -> None:
"""Stage LLM run metadata for this turn's prompt-recorder flush."""
self._pending_turn_llm = run
def pop_pending_turn_llm(self) -> Any | None:
"""Return and clear staged LLM run metadata for this turn."""
run = self._pending_turn_llm
self._pending_turn_llm = None
return run
def set_pending_turn_error(self, kind: str, message: str) -> None:
"""Stage a structured turn error for this turn's prompt-recorder flush."""
kind = kind.strip()
message = message.strip()
if kind or message:
self._pending_turn_error = (kind or "error", message)
def pop_pending_turn_error(self) -> tuple[str, str] | None:
"""Return and clear the staged structured turn error."""
error = self._pending_turn_error
self._pending_turn_error = None
return error
@@ -0,0 +1,49 @@
"""JSONL-backed :class:`~platform.observability.trace.spans.SessionTraceSink` for the REPL."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from core.agent_harness.session.persistence.jsonl_storage import JsonlSessionStorage
from platform.observability.trace.spans import NoopSessionTraceSink, SessionTraceSink
@dataclass
class JsonlSessionTraceSink:
"""Write ``trace_span`` records through the session's JSONL storage backend."""
storage: JsonlSessionStorage
def emit(
self,
session_id: str,
*,
span_kind: str,
name: str,
status: str = "ok",
duration_ms: int | None = None,
attributes: dict[str, Any] | None = None,
parent_id: str | None = None,
) -> str:
return self.storage.append_trace_span(
session_id,
span_kind=span_kind,
name=name,
status=status,
duration_ms=duration_ms,
attributes=attributes,
parent_id=parent_id,
)
def jsonl_trace_sink_for_session(session: Any) -> SessionTraceSink:
"""Return a JSONL sink wired to ``session.storage``, or a Noop sink for
non-JSONL (e.g. in-memory) sessions so tests don't leak trace files to disk."""
storage = getattr(session, "storage", None)
if not isinstance(storage, JsonlSessionStorage):
return NoopSessionTraceSink()
return JsonlSessionTraceSink(storage=storage)
__all__ = ["JsonlSessionTraceSink", "jsonl_trace_sink_for_session"]
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from platform.terminal.theme import (
ANSI_DIM,
ANSI_RESET,
BG,
BOLD_BRAND,
DEVICE_CODE,
DEVICE_CODE_ANSI,
DIM,
DIM_COUNTER_ANSI,
ERROR,
HIGHLIGHT,
MARKDOWN_THEME,
PROMPT_ACCENT_ANSI,
PROMPT_FRAME_ANSI,
SECONDARY,
TEXT,
WARNING,
)
from surfaces.interactive_shell.ui.banner import render_banner, render_ready_box
from surfaces.interactive_shell.ui.components import (
print_valid_choice_list,
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
from surfaces.interactive_shell.ui.components.rendering import (
print_repl_json,
print_repl_table,
refresh_welcome_poster,
repl_print,
repl_table,
)
if TYPE_CHECKING:
from surfaces.interactive_shell.ui.agents.agents_view import (
_build_agents_table,
render_agents_table,
)
from surfaces.interactive_shell.ui.streaming import (
STREAM_LABEL_ANSWER,
STREAM_LABEL_ASSISTANT,
stream_to_console,
)
from surfaces.interactive_shell.ui.tables import (
MCP_INTEGRATION_SERVICES,
ColumnDef,
print_command_output,
render_integrations_table,
render_mcp_table,
render_models_table,
render_table,
render_tools_table,
resolve_provider_models,
)
# Heavy re-exports resolved lazily so importing ``ui`` (done on every REPL boot
# via the prompt/completion path) does not force the table + streaming stack.
_LAZY_SUBMODULE_EXPORTS: dict[str, str] = {
"_build_agents_table": "surfaces.interactive_shell.ui.agents",
"render_agents_table": "surfaces.interactive_shell.ui.agents",
"STREAM_LABEL_ANSWER": "surfaces.interactive_shell.ui.streaming",
"STREAM_LABEL_ASSISTANT": "surfaces.interactive_shell.ui.streaming",
"stream_to_console": "surfaces.interactive_shell.ui.streaming",
"MCP_INTEGRATION_SERVICES": "surfaces.interactive_shell.ui.tables",
"ColumnDef": "surfaces.interactive_shell.ui.tables",
"print_command_output": "surfaces.interactive_shell.ui.tables",
"render_integrations_table": "surfaces.interactive_shell.ui.tables",
"render_mcp_table": "surfaces.interactive_shell.ui.tables",
"render_models_table": "surfaces.interactive_shell.ui.tables",
"render_table": "surfaces.interactive_shell.ui.tables",
"render_tools_table": "surfaces.interactive_shell.ui.tables",
"resolve_provider_models": "surfaces.interactive_shell.ui.tables",
}
def __getattr__(name: str) -> Any:
module_path = _LAZY_SUBMODULE_EXPORTS.get(name)
if module_path is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
return getattr(importlib.import_module(module_path), name)
__all__ = [
"ANSI_DIM",
"ANSI_RESET",
"BG",
"BOLD_BRAND",
"ColumnDef",
"DEVICE_CODE",
"DEVICE_CODE_ANSI",
"DIM",
"DIM_COUNTER_ANSI",
"ERROR",
"HIGHLIGHT",
"MCP_INTEGRATION_SERVICES",
"MARKDOWN_THEME",
"PROMPT_ACCENT_ANSI",
"PROMPT_FRAME_ANSI",
"SECONDARY",
"STREAM_LABEL_ANSWER",
"STREAM_LABEL_ASSISTANT",
"TEXT",
"WARNING",
"_build_agents_table",
"print_valid_choice_list",
"print_command_output",
"print_repl_json",
"print_repl_table",
"render_agents_table",
"refresh_welcome_poster",
"render_banner",
"render_ready_box",
"render_integrations_table",
"render_mcp_table",
"render_models_table",
"render_table",
"render_tools_table",
"repl_choose_one",
"repl_print",
"repl_section_break",
"repl_table",
"repl_tty_interactive",
"resolve_provider_models",
"stream_to_console",
]
@@ -0,0 +1,94 @@
"""Rendering for the shell tool-calling turn.
This module owns the terminal-facing action observer. Planner tool calls are
internal state by default: the observer records them for history/storage while
the concrete action executors render user-facing command output. The execution
orchestration that drives it lives in
:func:`interactive_shell.runtime.action_turn.run_action_tool_turn`.
Keeping rendering here means the shell turn-entry adapter stays focused on
binding core ports while terminal formatting stays in ``ui/``.
"""
from __future__ import annotations
import contextlib
import json
from typing import Any
from rich.console import Console
from core.agent_harness.turns.action_driver import SELF_RECORDING_ACTION_TOOL_NAMES
from surfaces.interactive_shell.runtime import Session
# Tools whose preview is just ``(label, single-arg)``. The display content is the
# stripped string value of that single argument. Anything that needs to combine
# multiple arguments (``slash_invoke``, ``synthetic_run``) keeps a custom branch
# in :func:`tool_call_display`.
_SIMPLE_TOOL_LABELS: dict[str, tuple[str, str]] = {
"llm_set_provider": ("LLM provider", "target"),
"alert_sample": ("sample alert", "template"),
"investigation_start": ("investigation", "alert_text"),
"task_cancel": ("cancel task", "target"),
"cli_exec": ("opensre", "payload"),
"code_implement": ("implementation", "task"),
"shell_run": ("shell", "command"),
}
def tool_call_display(tool_name: str, args: dict[str, Any]) -> tuple[str, str]:
"""Return a ``(label, content)`` pair describing a planned tool call."""
if tool_name == "slash_invoke":
command = str(args.get("command", "")).strip()
raw_args = args.get("args")
parsed_args = [str(item).strip() for item in raw_args] if isinstance(raw_args, list) else []
return "command", " ".join([command, *parsed_args]).strip()
if tool_name == "synthetic_run":
suite = str(args.get("suite", "")).strip()
scenario = str(args.get("scenario", "")).strip()
return "synthetic test", f"{suite}:{scenario}" if scenario else suite
simple = _SIMPLE_TOOL_LABELS.get(tool_name)
if simple is not None:
label, arg_key = simple
return label, str(args.get(arg_key, "")).strip()
return tool_name, json.dumps(args, default=str, sort_keys=True)
class ActionRenderObserver:
"""Agent event observer that records planner turns not owned by action tools.
Self-recording tools (``slash_invoke``, ``shell_run``, etc.) append their own
history row; chat turns are recorded later by turn accounting when the
assistant runs.
"""
def __init__(self, *, session: Session, console: Console, message: str) -> None:
self.session = session
self.console = console
self.message = message
self.planned_count = 0
def __call__(self, kind: str, data: dict[str, Any]) -> None:
if kind == "tool_update":
with contextlib.suppress(Exception):
self.session.storage.append_tool_update(
self.session.session_id,
tool=str(data.get("name") or "tool"),
update=data.get("update"),
tool_call_id=str(data.get("id") or "") or None,
)
return
if kind != "tool_start":
return
name = str(data.get("name", "")).strip()
if not name or name == "assistant_handoff":
return
if self.planned_count == 0 and name not in SELF_RECORDING_ACTION_TOOL_NAMES:
self.session.record("cli_agent", self.message)
self.planned_count += 1
__all__ = [
"ActionRenderObserver",
"tool_call_display",
]
@@ -0,0 +1,8 @@
"""Fleet and background-agent dashboard rendering."""
from surfaces.interactive_shell.ui.agents.agents_view import (
_build_agents_table,
render_agents_table,
)
__all__ = ["_build_agents_table", "render_agents_table"]
@@ -0,0 +1,144 @@
"""Rich-table rendering for the ``/fleet`` slash-command dashboard.
Columns: ``agent``, ``pid``, ``uptime``, ``cpu%``, ``tokens/min``,
``$/hr``, ``status``. Every metric cell falls back to ``-`` when its
sampler accessor returns ``None``. ``0`` versus ``-`` is meaningful
in ``tokens/min``: ``0`` is observed-but-idle, ``-`` is unobservable
(no meter for this provider, or the JSONL is unreadable, or the
sampler task is not running — e.g. non-interactive
``opensre fleet list``).
This module lives outside ``tools/system/fleet_monitoring/`` so collectors don't pull
in Rich.
"""
from __future__ import annotations
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
from rich.console import Console, JustifyMethod
from rich.markup import escape
from rich.table import Table
from platform.terminal.theme import BOLD_BRAND
from surfaces.interactive_shell.ui.components.rendering import print_repl_table, repl_table
from tools.system.fleet_monitoring.registry import AgentRecord
from tools.system.fleet_monitoring.sampler import get_snapshot, get_tokens_per_min, get_usd_per_hour
from tools.system.fleet_monitoring.status import Status, compute_status
_UNFILLED = "-"
# Re-using Rich's own ``JustifyMethod`` so column-justify options
# stay in lockstep with the library.
_COLUMNS: tuple[tuple[str, JustifyMethod], ...] = (
("agent", "left"),
("pid", "right"),
("uptime", "right"),
("cpu%", "right"),
("tokens/min", "right"),
("$/hr", "right"),
("status", "left"),
)
_STATUS_COLORS: dict[Status, str] = {
Status.ACTIVE: "green",
Status.IDLE: "yellow",
Status.STUCK: "red",
}
def _format_uptime(delta: timedelta) -> str:
total_seconds = int(delta.total_seconds())
if total_seconds < 60:
return f"{total_seconds}s"
if total_seconds < 3600:
return f"{total_seconds // 60}m"
hours = total_seconds // 3600
if hours < 24:
minutes = (total_seconds % 3600) // 60
return f"{hours}h{minutes}m"
days = hours // 24
remaining_hours = hours % 24
return f"{days}d{remaining_hours}h"
def _format_tokens_per_min(value: float | None) -> str:
if value is None:
return _UNFILLED
# Round-then-compare so ``999.6`` doesn't render as 4-digit ``"1000"``
# next to its 3-digit neighbors.
rounded = int(round(value))
if rounded < 1000:
return f"{rounded}"
return f"{value / 1000:.1f}k"
def _format_usd_per_hour(value: float | None) -> str:
if value is None:
return _UNFILLED
return f"${value:.2f}"
def _format_status(status: Status, msg: str = "") -> str:
"""Return a Rich-markup-colorized status cell for the /fleet table."""
color = _STATUS_COLORS.get(status, "default")
label = f"{status.value} ({msg})" if msg else status.value
return f"[{color}]{label}[/{color}]"
def _build_agents_table(records: Iterable[AgentRecord]) -> Table:
"""Build and return the agents dashboard Table without printing it."""
materialized = list(records)
table = repl_table(
title="agents",
title_style=BOLD_BRAND,
caption="no agents discovered or registered yet" if not materialized else None,
)
for header, justify in _COLUMNS:
table.add_column(header, justify=justify)
now = datetime.now(UTC)
for record in materialized:
snapshot = get_snapshot(record.pid)
if snapshot is not None:
# Use output freshness when available; otherwise the status
# heuristic falls back to the process start time.
status = compute_status(
snapshot,
now,
last_output_at=snapshot.last_output_at,
idle_after_s=120,
stuck_after_s=480,
)
status_msg = ""
if status is Status.STUCK:
anchor = snapshot.last_output_at or snapshot.started_at
status_msg = f"{_format_uptime(now - anchor)} no progress"
uptime_cell = _format_uptime(now - snapshot.started_at)
cpu_cell = f"{snapshot.cpu_percent:.1f}"
status_cell = _format_status(status, status_msg)
else:
uptime_cell = _UNFILLED
cpu_cell = _UNFILLED
status_cell = _UNFILLED
tokens_cell = _format_tokens_per_min(get_tokens_per_min(record.pid))
hourly_cell = _format_usd_per_hour(get_usd_per_hour(record.pid))
table.add_row(
escape(record.name),
str(record.pid),
uptime_cell,
cpu_cell,
tokens_cell,
hourly_cell,
status_cell,
)
return table
def render_agents_table(console: Console, records: Iterable[AgentRecord]) -> None:
"""Print the agents dashboard table to the REPL console with TTY-safe width."""
print_repl_table(console, _build_agents_table(records))
__all__ = ["_build_agents_table", "render_agents_table"]
@@ -0,0 +1,110 @@
"""Rich rendering for incoming alerts surfaced in the REPL loop.
The alert receiver, queue, and listener lifecycle live in
``core.domain.alerts.inbox``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
from core.domain.alerts.inbox import IncomingAlert
from platform.terminal.theme import (
DIM,
INCOMING_ALERT_ACCENT,
TEXT,
)
if TYPE_CHECKING:
from rich.console import RenderableType
from core.domain.alerts.inbox import AlertInbox
from surfaces.interactive_shell.runtime import Session
def time_ago(then: datetime | None) -> str:
"""Format a relative time string like '5 seconds ago', '1 minute ago', etc."""
if then is None:
return "unknown"
now = datetime.now(UTC)
delta = now - then
seconds = int(delta.total_seconds())
if seconds < 60:
return f"{seconds}s ago" if seconds != 1 else "1s ago"
elif seconds < 3600:
minutes = seconds // 60
return f"{minutes}m ago" if minutes != 1 else "1m ago"
elif seconds < 86400:
hours = seconds // 3600
return f"{hours}h ago" if hours != 1 else "1h ago"
else:
days = seconds // 86400
return f"{days}d ago" if days != 1 else "1d ago"
def format_incoming_alert(alert: IncomingAlert) -> RenderableType:
"""Format an incoming alert as a Rich renderable with distinct styling.
Returns a Panel with:
- Header showing incoming alert label, source, and severity (if present)
- Relative received time
- Alert text body
"""
# Build the header line: source and severity
header_parts: list[str] = ["incoming alert"]
if alert.source:
header_parts.append(f"from {escape(alert.source)}")
if alert.severity:
# Escape the whole `[severity]` fragment so Rich cannot treat `[bold ...]` etc. as tags.
header_parts.append(escape(f"[{alert.severity}]"))
header = " | ".join(header_parts)
# Format the alert body with timestamp
timestamp_str = time_ago(alert.received_at)
body_lines = [
f"[{DIM}]received {timestamp_str}[/]",
"",
f"[{TEXT}]{escape(alert.text)}[/]",
]
body = "\n".join(body_lines)
# Create a panel with the distinct accent
panel = Panel(
body,
title=f"[{INCOMING_ALERT_ACCENT}]⚠ {header}[/]",
expand=False,
border_style=INCOMING_ALERT_ACCENT,
)
return panel
def drain_and_render_incoming(
session: Session,
console: Console,
inbox: AlertInbox,
) -> int:
"""Pop all queued alerts, render each one, and record them in session.
Returns the number of alerts rendered.
"""
alerts = inbox.iter_pending()
count = 0
for alert in alerts:
console.print(format_incoming_alert(alert), end="\n")
session.record_incoming_alert(alert)
count += 1
return count
__all__ = ["format_incoming_alert", "drain_and_render_incoming", "time_ago"]
@@ -0,0 +1,17 @@
"""Startup splash and REPL welcome banner."""
from surfaces.interactive_shell.ui.banner.banner import (
build_ready_panel,
render_banner,
render_ready_box,
render_splash,
)
from surfaces.interactive_shell.ui.banner.banner_state import integration_display_name
__all__ = [
"build_ready_panel",
"integration_display_name",
"render_banner",
"render_ready_box",
"render_splash",
]
@@ -0,0 +1,411 @@
"""Splash screen, agent ready-state box, and REPL launch banner.
Three exported entry points
---------------------------
render_splash(console, first_run=False)
Full branded startup screen with ASCII art and optional security gate.
Called once when the CLI starts.
render_ready_box(console, session=None)
DIM-bordered two-column welcome panel:
left → ◉ OpenSRE · provider · model · mode · cwd
right → "Tips for getting started" + "What's new"
Called after the splash and on /clear, /welcome, and greeting aliases.
render_banner(console)
Backward-compatible shim: render_splash + render_ready_box in one call.
Existing callers continue to work unchanged.
Rendered output legend (colour roles)
--------------------------------------
# [HIGHLIGHT] ASCII art lines · ◉ glyph · OpenSRE brand name
# [BRAND] version string · model name · section headers
# [SECONDARY] "opensre" product name label · cwd · tip / note body
# [DIM] subtitle description · rule lines · box chrome · dividers
# [TEXT] provider/model values · greeting
# [WARNING] read-only or trust-mode notice · incomplete-integration marker
"""
from __future__ import annotations
import getpass
import math
import os
import sys
from rich import box
from rich.console import Console, Group
from rich.panel import Panel
from rich.rule import Rule
from rich.table import Table
from rich.text import Text
from config.repl_config import WHATS_NEW
from config.version import get_opensre_version
from platform.terminal.theme import (
BRAND,
DIM,
HIGHLIGHT,
SECONDARY,
TEXT,
WARNING,
_parse_hex_color,
get_active_theme,
)
from surfaces.interactive_shell.ui.banner.banner_state import _build_ambient_right_column
from surfaces.interactive_shell.ui.components.banner_art import _render_art
from surfaces.interactive_shell.ui.tables.provider import detect_provider_model
def _is_first_run() -> bool:
"""True when the wizard has never been completed on this machine."""
try:
from surfaces.cli.wizard.store import get_store_path
return not get_store_path().exists()
except Exception:
return False
# ── Splash screen ─────────────────────────────────────────────────────────────
def _interpolate_hex_color(start: str, end: str, t: float) -> str:
"""Return a hex color linearly interpolated between ``start`` and ``end``."""
start_rgb = _parse_hex_color(start)
end_rgb = _parse_hex_color(end)
clamped = max(0.0, min(1.0, t))
channels = tuple(
int(round(start_rgb[idx] + (end_rgb[idx] - start_rgb[idx]) * clamped)) for idx in range(3)
)
return f"#{channels[0]:02X}{channels[1]:02X}{channels[2]:02X}"
def _splash_block_style(block_index: int, block_total: int) -> str:
"""Return the Rich style for one splash block character."""
theme = get_active_theme()
start = theme.SPLASH_GRADIENT_START
end = theme.SPLASH_GRADIENT_END
if not start or not end:
return f"bold {HIGHLIGHT}"
if block_total <= 1:
return f"bold {_interpolate_hex_color(start, end, 0.0)}"
ratio = block_index / (block_total - 1)
return f"bold {_interpolate_hex_color(start, end, ratio)}"
def render_splash(console: Console | None = None, *, first_run: bool | None = None) -> None:
"""Print the branded startup splash.
Rendered output (with colour roles):
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ [DIM divider]
╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋ [HIGHLIGHT art]
...
opensre [SECONDARY] · v<version> [BRAND]
open-source SRE agent for automated incident
investigation and root cause analysis [DIM]
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ [DIM divider]
If first_run (or not set and wizard has never run):
⚠ This tool runs AI-powered commands … [WARNING]
Press Enter to continue… [SECONDARY]
"""
console = console or Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
if first_run is None:
first_run = _is_first_run()
version = get_opensre_version()
art = _render_art(console.width)
console.print()
console.print(Rule(style=DIM))
console.print()
for line in art.splitlines():
t = Text()
t.append(" ")
block_total = line.count("")
block_index = 0
for ch in line:
if ch == "":
t.append(ch, style=_splash_block_style(block_index, block_total))
block_index += 1
else:
t.append(ch, style=f"bold {BRAND}")
console.print(t)
console.print()
subtitle = Text()
subtitle.append(" ")
subtitle.append("opensre", style=SECONDARY)
subtitle.append(" · ", style=DIM)
subtitle.append(f"v{version}", style=BRAND)
console.print(subtitle)
desc = Text()
desc.append(
" open-source SRE agent for automated incident investigation and root cause analysis",
style=DIM,
)
console.print(desc)
console.print()
console.print(Rule(style=DIM))
if first_run:
console.print()
notice = Text()
notice.append(" ")
notice.append("", style=f"bold {WARNING}")
notice.append(
"This tool executes AI-powered commands against your infrastructure.\n"
" Review the documentation before connecting production systems.\n"
" Source: https://github.com/opensre-dev/opensre",
style=SECONDARY,
)
console.print(notice)
console.print()
if sys.stdin.isatty():
try:
console.print(f" [{SECONDARY}]Press Enter to continue…[/]", end="")
sys.stdin.readline()
except (EOFError, KeyboardInterrupt, OSError):
# Non-interactive stdin or user abort — skip blocking and continue startup.
pass
console.print()
# ── Agent ready-state box ─────────────────────────────────────────────────────
# Static copy for the right column (first-run only). Keep entries terse.
_TIPS: tuple[str, ...] = (
"Paste alert JSON or describe an incident",
"Type /help to list slash commands",
"Run /doctor for environment diagnostics",
"Use /investigate for runnable demos/templates",
)
# Panel geometry. The body switches to a stacked layout on narrow terminals,
# and otherwise expands to fill the full console width while keeping the left
# identity column readable and the right notes column roomy.
_MIN_LEFT_COL_WIDTH = 34
_MAX_LEFT_COL_WIDTH = 48
_MIN_RIGHT_COL_WIDTH = 40
_DIVIDER_WIDTH = 3
_PANEL_PADDING_X = 2
_PANEL_FRAME_WIDTH = 2 + (_PANEL_PADDING_X * 2)
_MIN_TWO_COLUMN_CONTENT_WIDTH = _MIN_LEFT_COL_WIDTH + _DIVIDER_WIDTH + _MIN_RIGHT_COL_WIDTH
# OpenSRE brand mark — single "O" from oh-my-logo tiny font (half-block chars).
_LOGO_MARK_ROWS: tuple[tuple[str, str], ...] = (
("█▀█", ""),
("█▄█", ""),
)
def _github_username() -> str:
"""Return the saved GitHub login for the configured GitHub integration, or "".
Best-effort and never raises: the welcome greeting must render even when the
integration store is unreadable or GitHub is not configured.
"""
try:
from integrations.github.identity import saved_github_username
return saved_github_username()
except Exception:
return ""
def _get_username() -> str:
# Prefer the authenticated GitHub handle once it is known, so the greeting
# reflects the user's GitHub identity rather than the local system account.
github = _github_username()
if github:
return github
try:
return getpass.getuser()
except Exception:
return "there"
def _build_logo_mark() -> Text:
"""Return the brand mark left-aligned (flush with the column's 2-space indent)."""
logo = Text(no_wrap=True)
for index, (body, _echo) in enumerate(_LOGO_MARK_ROWS):
if index:
logo.append("\n")
logo.append(body, style=f"bold {HIGHLIGHT}")
return logo
def _format_cwd(path: str) -> str:
"""Collapse the user's home directory to ~ for a tidier identity line."""
home = os.path.expanduser("~")
if home and (path == home or path.startswith(home + os.sep)):
return "~" + path[len(home) :]
return path
def _build_identity_block(provider: str, model: str, *, trust_mode: bool) -> Text:
"""Left column: mascot · blank · greeting · blank · identity line (all left-aligned)."""
logo = _build_logo_mark()
greeting = Text()
greeting.append(f"Welcome back {_get_username()}!", style=f"bold {TEXT}")
# Single flowing line: model · tier · workspace
cwd = _format_cwd(os.getcwd())
tier = "trust mode" if trust_mode else provider
identity = Text(overflow="fold")
identity.append(model, style=f"bold {BRAND}")
identity.append(" · ", style=DIM)
if trust_mode:
identity.append(tier, style=f"bold {WARNING}")
identity.append(" · ", style=DIM)
else:
identity.append(tier, style=SECONDARY)
identity.append(" · ", style=DIM)
identity.append(cwd, style=SECONDARY)
return Text("\n").join([logo, Text(), Text(), greeting, Text(), Text(), identity])
def _build_notes_block(header_text: str, items: tuple[str, ...]) -> Text:
"""Right column section: bold header followed by dim list items."""
parts: list[Text] = [Text(header_text, style=f"bold {BRAND}")]
for item in items:
parts.append(Text(item, style=SECONDARY, overflow="fold"))
return Text("\n").join(parts)
def _visual_line_count(block: Text, width: int) -> int:
"""Estimate how many terminal lines a Text block will occupy at ``width``."""
safe_width = max(width, 1)
total = 0
for raw_line in block.plain.split("\n"):
total += max(1, math.ceil(max(len(raw_line), 1) / safe_width))
return total
def _vertical_divider(height: int) -> Text:
"""Build a padded vertical rule with ``height`` lines."""
return Text("\n".join("" for _ in range(max(height, 1))), style=DIM, no_wrap=True)
def _two_column_widths(console_width: int) -> tuple[int, int]:
"""Return responsive left/right widths for the ready panel body."""
content_width = max(console_width - _PANEL_FRAME_WIDTH, _MIN_TWO_COLUMN_CONTENT_WIDTH)
left_width = int((content_width - _DIVIDER_WIDTH) * 0.42)
left_width = max(_MIN_LEFT_COL_WIDTH, min(left_width, _MAX_LEFT_COL_WIDTH))
right_width = content_width - _DIVIDER_WIDTH - left_width
if right_width < _MIN_RIGHT_COL_WIDTH:
right_width = _MIN_RIGHT_COL_WIDTH
left_width = content_width - _DIVIDER_WIDTH - right_width
return left_width, right_width
def build_ready_panel(
console: Console | None = None,
*,
session: object = None,
) -> Panel:
"""Build the responsive welcome panel shared by startup and CLI help."""
console = console or Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
provider, model = detect_provider_model()
version = get_opensre_version()
trust_mode: bool = bool(getattr(session, "trust_mode", False))
panel_title = Text()
panel_title.append(" OpenSRE", style=f"bold {HIGHLIGHT}")
panel_title.append(" · ", style=DIM)
panel_title.append(f"v{version} ", style=BRAND)
left = _build_identity_block(provider, model, trust_mode=trust_mode)
if _is_first_run():
right = Text("\n").join(
[
_build_notes_block("Tips for getting started", _TIPS),
Text("───", style=DIM),
_build_notes_block("What's new", WHATS_NEW),
]
)
else:
right = _build_ambient_right_column(session=session)
body: Group | Table
if console.width - _PANEL_FRAME_WIDTH >= _MIN_TWO_COLUMN_CONTENT_WIDTH:
left_width, right_width = _two_column_widths(console.width)
height = max(
_visual_line_count(left, left_width),
_visual_line_count(right, right_width),
)
divider = _vertical_divider(height)
grid = Table.grid(padding=0, expand=False)
grid.add_column(justify="left", vertical="top", width=left_width)
grid.add_column(justify="center", vertical="top", width=_DIVIDER_WIDTH)
grid.add_column(justify="left", vertical="top", width=right_width)
grid.add_row(left, divider, right)
body = grid
else:
body = Group(
left,
Rule(style=DIM),
right,
)
return Panel(
body,
title=panel_title,
title_align="left",
border_style=DIM,
padding=(1, _PANEL_PADDING_X),
expand=True,
box=box.ROUNDED,
)
def render_ready_box(
console: Console | None = None,
*,
session: object = None,
) -> None:
"""Print the two-column welcome panel with an embedded title bar."""
console = console or Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
console.print()
console.print(build_ready_panel(console, session=session))
console.print()
# ── Backward-compatible shim ──────────────────────────────────────────────────
def render_banner(console: Console | None = None) -> None:
"""Render splash + ready-state box in one call (legacy entry point).
Existing callers (main.run_repl) continue to work unchanged.
"""
_console = console or Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
render_splash(_console)
render_ready_box(_console)
@@ -0,0 +1,147 @@
"""Live system state helpers for the agent ready-state banner.
Queries integration health and alert-listener config without making
network calls — results are cached-once per banner render and used by
:func:`interactive_shell.ui.banner.banner._build_ambient_right_column`.
"""
from __future__ import annotations
from rich.text import Text
from platform.terminal.theme import BRAND, DIM, HIGHLIGHT, SECONDARY, WARNING
# Display-name overrides for known integration service slugs.
_SERVICE_DISPLAY_NAMES: dict[str, str] = {
"grafana": "Grafana",
"datadog": "Datadog",
"honeycomb": "Honeycomb",
"coralogix": "Coralogix",
"aws": "AWS",
"github": "GitHub",
"sentry": "Sentry",
"prometheus": "Prometheus",
"loki": "Loki",
"elasticsearch": "Elasticsearch",
"bigquery": "BigQuery",
"pagerduty": "PagerDuty",
"slack": "Slack",
"telegram": "Telegram",
"signoz": "SigNoz",
"jira": "Jira",
"gitlab": "GitLab",
"vercel": "Vercel",
"mongodb": "MongoDB",
"postgresql": "PostgreSQL",
"mysql": "MySQL",
"redis": "Redis",
"kafka": "Kafka",
"rabbitmq": "RabbitMQ",
"clickhouse": "ClickHouse",
"mariadb": "MariaDB",
"kubernetes": "Kubernetes",
"betterstack": "Better Stack",
"snowflake": "Snowflake",
"newrelic": "New Relic",
"opsgenie": "OpsGenie",
"linear": "Linear",
"supabase": "Supabase",
}
def integration_display_name(service: str) -> str:
"""Human-readable label for an integration service slug."""
return _SERVICE_DISPLAY_NAMES.get(service, service.replace("_", " ").title())
def _load_integration_health() -> list[tuple[str, str]]:
"""Return ``(display_name, status)`` for each configured integration.
``status`` is ``"ok"`` or ``"incomplete"`` (e.g. a hosted MCP record saved
without an API token). Offline and best-effort: never raises and never makes
network calls, so the banner reflects health without slowing startup.
"""
try:
from integrations.catalog import ( # lazy — avoids circular deps
configured_integration_health,
)
return [
(integration_display_name(service), status)
for service, status in configured_integration_health()
]
except Exception:
return []
def _is_alert_listener_active() -> bool:
"""Return True if the alert listener is enabled in config. Never raises."""
try:
from config.repl_config import ReplConfig
return ReplConfig.load(apply_active_theme=False).alert_listener_enabled
except Exception:
return False
def _build_ambient_right_column(session: object = None) -> Text:
"""Right column for returning users: live integration status and alert listener state."""
parts: list[Text] = []
# Integrations — annotate by offline health so the banner never implies a
# half-configured integration (e.g. a hosted MCP record with no API token)
# is connected. A "⚠" + dim name marks an integration missing credentials.
parts.append(Text("Integrations", style=f"bold {BRAND}"))
entries = _load_integration_health()
if entries:
_MAX_SHOWN = 6
shown = entries[:_MAX_SHOWN]
overflow = len(entries) - len(shown)
name_line = Text(overflow="fold")
for idx, (name, status) in enumerate(shown):
if idx:
name_line.append(" · ", style=DIM)
if status == "incomplete":
name_line.append(f"{name}", style=DIM)
else:
name_line.append(name, style=SECONDARY)
if overflow:
name_line.append(f" +{overflow}", style=DIM)
parts.append(name_line)
if any(status == "incomplete" for _name, status in entries):
parts.append(Text("⚠ incomplete — run /integrations verify", style=WARNING))
else:
parts.append(Text("run /onboard to connect tools", style=DIM))
parts.append(Text("───", style=DIM))
# Alert listener
parts.append(Text("Alert listener", style=f"bold {BRAND}"))
if _is_alert_listener_active():
listener_line = Text()
listener_line.append("", style=f"bold {HIGHLIGHT}")
listener_line.append("active", style=SECONDARY)
parts.append(listener_line)
else:
parts.append(Text("○ not configured", style=DIM))
# Session summary — only shown when /clear is used mid-session with history
if session is not None:
history: list[object] = getattr(session, "history", [])
if history:
parts.append(Text("───", style=DIM))
parts.append(Text("This session", style=f"bold {BRAND}"))
count = len(history)
noun = "interaction" if count == 1 else "interactions"
parts.append(Text(f"{count} {noun}", style=SECONDARY))
return Text("\n").join(parts)
__all__ = [
"_SERVICE_DISPLAY_NAMES",
"integration_display_name",
"_build_ambient_right_column",
"_is_alert_listener_active",
"_load_integration_health",
]
@@ -0,0 +1,42 @@
"""Reusable terminal UI primitives (menus, TTY rendering, formatting)."""
from surfaces.interactive_shell.ui.components.choice_menu import (
print_valid_choice_list,
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
from surfaces.interactive_shell.ui.components.loaders import DEFAULT_LOADER_LABEL, llm_loader
from surfaces.interactive_shell.ui.components.rendering import (
print_repl_json,
print_repl_table,
refresh_welcome_poster,
repl_print,
repl_table,
)
from surfaces.interactive_shell.ui.components.time_format import (
format_repl_duration,
format_repl_timestamp,
)
from surfaces.interactive_shell.ui.components.token_format import (
_CHARS_PER_TOKEN,
format_token_count_short,
)
__all__ = [
"DEFAULT_LOADER_LABEL",
"_CHARS_PER_TOKEN",
"format_repl_duration",
"format_repl_timestamp",
"format_token_count_short",
"llm_loader",
"print_repl_json",
"print_repl_table",
"print_valid_choice_list",
"refresh_welcome_poster",
"repl_choose_one",
"repl_print",
"repl_section_break",
"repl_table",
"repl_tty_interactive",
]
@@ -0,0 +1,74 @@
"""ASCII splash art and art-selection logic for the startup screen.
Exported
--------
SPLASH_ART block font, 59 cols, solid ██ fills
SPLASH_ART_NARROW simpleBlock font, 72 cols, pure ASCII fallback
_FALLBACK_ART minimal art, 44 cols, last resort
_render_art(width) return the best-fit art string for a given terminal width
"""
from __future__ import annotations
import os
from platform.observability.render.figlet import render_figlet
# Pre-rendered during development and checked into this module as a static string.
# Colour codes are stripped; HIGHLIGHT is re-applied at render time.
SPLASH_ART = """\
██████╗ ██████╗ ███████╗███╗ ██╗███████╗██████╗ ███████╗
██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔══██╗██╔════╝
██║ ██║██████╔╝█████╗ ██╔██╗ ██║███████╗██████╔╝█████╗
██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║╚════██║██╔══██╗██╔══╝
╚██████╔╝██║ ███████╗██║ ╚████║███████║██║ ██║███████╗
╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝╚══════╝"""
SPLASH_ART_NARROW = """\
_|_| _|_|_| _|_|_|_| _| _| _|_|_| _|_|_| _|_|_|_|
_| _| _| _| _| _|_| _| _| _| _| _|
_| _| _|_|_| _|_|_| _| _| _| _|_| _|_|_| _|_|_|
_| _| _| _| _| _|_| _| _| _| _|
_|_| _| _|_|_|_| _| _| _|_|_| _| _| _|_|_|_|"""
_FALLBACK_ART = """\
___ ____ ____ _____
/ _ \\ _ __ ___ _ __ / ___|| _ \\| ____|
| | | | '_ \\ / _ \\ '_ \\ \\___ \\| |_) | _|
| |_| | |_) | __/ | | | ___) | _ <| |___
\\___/| .__/ \\___|_| |_||____/|_| \\_\\_____|
|_|"""
def _render_art(console_width: int = 80) -> str:
"""Return the splash art string for the given terminal width.
Priority: SPLASH_ART (grid, 34 cols) → SPLASH_ART_NARROW (simpleBlock, 72 cols)
→ _FALLBACK_ART (minimal, 44 cols). OPENSRE_FIGLET_FONT overrides the default
when pyfiglet is installed.
"""
custom_font = os.getenv("OPENSRE_FIGLET_FONT")
if custom_font:
rendered = render_figlet("OpenSRE", font=custom_font, max_line_width=console_width - 2)
if rendered:
return rendered
art_width = max(len(ln) for ln in SPLASH_ART.splitlines())
narrow_width = max(len(ln) for ln in SPLASH_ART_NARROW.splitlines())
fallback_width = max(len(ln) for ln in _FALLBACK_ART.splitlines())
if console_width >= art_width + 4:
return SPLASH_ART
if console_width >= narrow_width + 4:
return SPLASH_ART_NARROW
if console_width >= fallback_width + 4:
return _FALLBACK_ART
return _FALLBACK_ART
__all__ = [
"SPLASH_ART",
"SPLASH_ART_NARROW",
"_FALLBACK_ART",
"_render_art",
]
@@ -0,0 +1,285 @@
"""Interactive choice helpers for TTY-first REPL flows.
Inline menus render in the terminal scrollback (below the submitted command),
not as a separate prompt-toolkit full-screen dialog — important when the REPL
already runs under asyncio.
Each menu erases itself on exit (selection or Esc) so nested menus never
pile up — only the result output and the next level appear on screen.
"""
from __future__ import annotations
import os
import shutil
import sys
from typing import Literal
from rich.console import Console
from rich.markup import escape
import platform.terminal.theme as ui_theme
from surfaces.interactive_shell.ui.components.key_reader import read_key_unix, read_key_windows
_HINT = "↑↓/j/k/Tab Enter/Space Esc/q"
CRUMB_SEP = " "
# Blank line after the submitted slash line before the menu header (all pickers).
_MENU_LEADING_LINES = 1
_TERMINAL_NEWLINE = "\r\n"
MenuAction = Literal["up", "down", "enter", "cancel", "eof", "ignore"]
def repl_tty_interactive() -> bool:
"""Return True when stdin/stdout support an interactive picker UI."""
return bool(sys.stdin.isatty() and sys.stdout.isatty())
def ensure_tty_column_zero() -> None:
"""Reset the cursor column before Rich output when a TTY is active."""
if repl_tty_interactive():
reset_tty_column()
def prepare_repl_output_line() -> None:
"""Begin Rich output on a new line after inline menu I/O."""
if repl_tty_interactive():
sys.stdout.write(_TERMINAL_NEWLINE)
reset_tty_column()
def repl_section_break(console: Console) -> None:
"""Blank line + dim rule between an inline menu step and Rich output."""
prepare_repl_output_line()
console.print()
console.rule(characters="", style=str(ui_theme.DIM))
console.print()
# ── raw key reader ───────────────────────────────────────────────────────────
def _read_action() -> MenuAction:
"""Map a raw keypress to a menu action.
Delegates terminal I/O to :mod:`key_reader` and applies
choice_menu-specific overrides: Tab → ``"down"``,
right-arrow → ``"enter"``, left-arrow → ``"ignore"``.
"""
key = read_key_windows() if os.name == "nt" else read_key_unix()
if key == "tab":
return "down"
if key == "right":
return "enter"
if key == "left":
return "ignore"
return key # type: ignore[return-value]
def read_menu_action() -> MenuAction:
"""Read one normalized inline-menu action from stdin."""
return _read_action()
# ── rendering helpers ────────────────────────────────────────────────────────
def _cols() -> int:
return max(40, shutil.get_terminal_size(fallback=(80, 24)).columns)
def menu_columns() -> int:
"""Return the current terminal width floor used by inline menus."""
return _cols()
def _rule(width: int) -> str:
return "" * width
def _pad(sym: str, label: str, width: int) -> str:
content = f" {sym} {label}"
pad = width - len(content)
return content + (" " * pad if pad > 0 else "")
def _menu_height(crumb: str, labels: list[str]) -> int:
# leading, title, [crumb], rule, blank, choices, blank, hint
return _MENU_LEADING_LINES + 1 + (1 if crumb else 0) + 1 + 1 + len(labels) + 1 + 1
def write_menu_line(text: str = "") -> None:
"""Write one inline-menu line at column zero even while the terminal is in raw mode."""
if text:
sys.stdout.write(f"\r{text}{_TERMINAL_NEWLINE}")
return
sys.stdout.write(_TERMINAL_NEWLINE)
def _erase_menu_block(height: int) -> None:
if height:
sys.stdout.write(f"\r\x1b[{height}A\r\x1b[J")
reset_tty_column()
def reset_tty_column() -> None:
"""Return the cursor to column zero after inline menu I/O.
Menu rows are padded to the terminal width, so the cursor often ends on a
high column. Rich output that follows must start at column zero or tables
render as a diagonal block of leading whitespace.
"""
sys.stdout.write("\r")
sys.stdout.flush()
def erase_menu_lines(height: int) -> None:
"""Erase a previously-rendered inline menu block."""
_erase_menu_block(height)
def _draw_menu(
*,
title: str,
crumb: str,
labels: list[str],
index: int,
erase_lines: int,
) -> None:
out = sys.stdout
w = _cols()
if erase_lines:
_erase_menu_block(erase_lines)
for _ in range(_MENU_LEADING_LINES):
write_menu_line()
# title
write_menu_line(f"{ui_theme.PROMPT_ACCENT_ANSI}{title}{ui_theme.ANSI_RESET}")
# breadcrumb path
if crumb:
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{crumb}{ui_theme.ANSI_RESET}")
# separator below header
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_rule(w)}{ui_theme.ANSI_RESET}")
write_menu_line()
# choices
for i, label in enumerate(labels):
here = i == index
sym = ">" if here else " "
padded = _pad(sym, label, w)
if here:
write_menu_line(f"{ui_theme.MENU_SELECTION_ROW_ANSI}{padded}{ui_theme.ANSI_RESET}")
else:
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{padded}{ui_theme.ANSI_RESET}")
write_menu_line()
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_HINT}{ui_theme.ANSI_RESET}")
out.flush()
def _erase_menu(crumb: str, labels: list[str]) -> None:
"""Move cursor up to the start of this menu block and wipe it."""
height = _menu_height(crumb, labels)
_erase_menu_block(height)
sys.stdout.flush()
# ── picker loop ──────────────────────────────────────────────────────────────
def _pick(
*,
title: str,
crumb: str,
labels: list[str],
initial_index: int = 0,
) -> int | None:
"""Draw an inline menu, let user navigate, erase on exit. Returns index or None."""
if not labels:
return None
idx = initial_index % len(labels)
height = _menu_height(crumb, labels)
first = True
while True:
_draw_menu(
title=title,
crumb=crumb,
labels=labels,
index=idx,
erase_lines=0 if first else height,
)
first = False
action = _read_action()
if action == "enter":
_erase_menu(crumb, labels)
return idx
if action in ("cancel", "eof"):
_erase_menu(crumb, labels)
return None
if action == "ignore":
continue
if action == "up":
idx = (idx - 1) % len(labels)
elif action == "down":
idx = (idx + 1) % len(labels)
# ── public API ───────────────────────────────────────────────────────────────
def repl_choose_one(
*,
title: str,
choices: list[tuple[str, str]],
breadcrumb: str = "",
initial_value: str | None = None,
) -> str | None:
"""Show an inline erasing arrow-key menu; return selected value or None on Esc.
``breadcrumb`` is a slash-separated path shown dimly below the title, e.g.
``/model set``. Only call when :func:`repl_tty_interactive` is True.
"""
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
if not choices or not repl_tty_interactive():
return None
drain_stale_cpr_bytes()
crumb = breadcrumb
labels = [label for _value, label in choices]
initial_index = 0
if initial_value is not None:
for index, (value, _label) in enumerate(choices):
if value == initial_value:
initial_index = index
break
picked = _pick(title=title, crumb=crumb, labels=labels, initial_index=initial_index)
if picked is None:
return None
value = choices[picked][0]
return value if isinstance(value, str) else None
def print_valid_choice_list(
console: Console,
*,
title: str,
choices: list[str],
) -> None:
"""Print one choice per line for scan-friendly fallback/error messaging."""
if not choices:
return
console.print(f"[{ui_theme.SECONDARY}]{title}[/]")
for choice in choices:
console.print(f"[{ui_theme.SECONDARY}] - {escape(choice)}[/]")
__all__ = [
"CRUMB_SEP",
"erase_menu_lines",
"menu_columns",
"print_valid_choice_list",
"read_menu_action",
"repl_choose_one",
"ensure_tty_column_zero",
"prepare_repl_output_line",
"repl_section_break",
"repl_tty_interactive",
"reset_tty_column",
"write_menu_line",
]
@@ -0,0 +1,70 @@
"""CPR (cursor position report) stdin hygiene for the interactive REPL loop."""
from __future__ import annotations
import os
import re
import select
import sys
# A leaked cursor-position reply is ``ESC[row;colR`` (8-bit CSI ``\x9b`` too); when it
# leaks into the input stream the ESC and/or ``[`` introducer can be lost. The
# introducer-less branches below are constrained so they only fire on genuine CPR
# context. Without that constraint they can silently strip legitimate input such as
# ``5R3``, ``12;34R okay`` or ``12;34R5 nodes``.
_CPR_SEQUENCE_RE = re.compile(
r"(?:\x1b\[|\x9b)\d{1,4};\d{1,4}R" # ESC [ row ; col R (introducer present)
r"|\[\d{1,4};\d{1,4}R" # [row;colR without ESC (leaked into input)
r"|\d{1,4};\d{1,4}R(?=[\[\x1b\x9b]|\d{1,4};\d{1,4}R)" # bare row;colR before another fragment
r"|\d{1,4}R(?=\[|\x1b|\x9b|\d{1,4};\d{1,4}R)" # bare rowR before another fragment
r"|\d{1,4};\d{1,4}R$" # bare row;colR alone at end of the line
)
_CPR_ESCAPED_SEQUENCE_RE = re.compile(r"(?:\x1b\[|\x9b)\d{1,4};\d{1,4}R")
def drain_stale_cpr_bytes() -> None:
"""Discard CPR escape-sequence bytes left in stdin after prompt teardown.
When ``prompt_async`` returns, prompt_toolkit tears down its input-reader
thread. CPR responses (``ESC[row;colR``) that the bottom-toolbar refresh
sent but that arrived just after the reader stopped sit in the OS stdin
buffer and appear as literal keystrokes in the next prompt. This function
non-blockingly drains stdin between ``prompt_async`` calls on POSIX TTYs.
"""
if os.name == "nt" or not sys.stdin.isatty():
return
try:
fd = sys.stdin.fileno()
while select.select([fd], [], [], 0)[0]:
chunk = os.read(fd, 256)
if not chunk:
break
except OSError:
# Draining stdin is best-effort; ignore when the fd is not readable.
pass
def strip_cpr_sequences(text: str | None) -> str:
"""Remove terminal cursor-position replies that leaked into submitted text."""
if not text:
return ""
return _CPR_SEQUENCE_RE.sub("", text)
def strip_cpr_escape_sequences(text: str | None) -> str:
"""Remove only canonical escaped CPR sequences from text."""
if not text:
return ""
return _CPR_ESCAPED_SEQUENCE_RE.sub("", text)
def contains_cpr_sequence(text: str | None) -> bool:
return bool(text and _CPR_SEQUENCE_RE.search(text))
__all__ = [
"contains_cpr_sequence",
"drain_stale_cpr_bytes",
"strip_cpr_escape_sequences",
"strip_cpr_sequences",
]
@@ -0,0 +1,152 @@
"""Low-level terminal key reader for TTY-first interactive menus.
Shared between :mod:`choice_menu` (REPL inline picker) and
:mod:`feedback` (post-investigation rating prompt) so the raw-mode
terminal I/O lives in one place.
Return values from :func:`read_key_unix` / :func:`read_key_windows`:
``"up"``, ``"down"``, ``"enter"``, ``"cancel"``, ``"tab"``,
``"right"``, ``"left"``, ``"eof"``, ``"ignore"``.
"""
from __future__ import annotations
import contextlib
import os
import sys
def flush_stdin_unix() -> None:
"""Discard pending stdin bytes before raw-mode reading."""
with contextlib.suppress(Exception):
import termios
termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH) # type: ignore[attr-defined]
def restore_stdin_terminal() -> None:
"""Return stdin to canonical echo mode after Live/raw investigation UI.
Investigation progress uses a background Ctrl+O watcher that puts stdin in
non-canonical mode without echo. If nested watchers restore the wrong
snapshot, the shell prompt appears to accept input but characters are not
echoed. Call this after investigation UI teardown and before line prompts.
"""
if os.name == "nt" or not sys.stdin.isatty():
return
import termios
with contextlib.suppress(Exception):
fd = sys.stdin.fileno()
attrs = termios.tcgetattr(fd) # type: ignore[attr-defined]
# Restore cooked-mode flags a raw menu clears: ICRNL so Enter (CR) submits,
# OPOST for output newlines, ICANON/ECHO/ISIG for line editing and signals.
attrs[0] |= termios.BRKINT | termios.ICRNL | termios.IXON # type: ignore[attr-defined]
attrs[1] |= termios.OPOST # type: ignore[attr-defined]
attrs[3] |= termios.ICANON | termios.ECHO | termios.ISIG # type: ignore[attr-defined]
if hasattr(termios, "IEXTEN"):
attrs[3] |= termios.IEXTEN # type: ignore[attr-defined]
termios.tcsetattr(fd, termios.TCSADRAIN, attrs) # type: ignore[attr-defined]
termios.tcflush(fd, termios.TCIFLUSH) # type: ignore[attr-defined]
def read_key_unix(*, also_cancel: tuple[bytes, ...] = ()) -> str:
"""Read one logical keypress in raw mode; return a normalised key name.
Possible return values: ``"up"``, ``"down"``, ``"enter"``,
``"cancel"``, ``"tab"``, ``"right"``, ``"left"``, ``"eof"``,
``"ignore"``.
``also_cancel`` treats additional single-byte keys as ``"cancel"`` (e.g.
``(b"s", b"S")`` for an explicit skip shortcut).
"""
import select as _sel
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd) # type: ignore[attr-defined]
try:
tty.setraw(fd) # type: ignore[attr-defined]
ch = os.read(fd, 1)
if not ch:
return "eof"
b = ch[0]
if b in (3, 4) or ch in also_cancel: # Ctrl-C / Ctrl-D / caller shortcuts
return "cancel"
if b in (10, 13, 32): # LF / CR / Space
return "enter"
if b == 9: # Tab
return "tab"
if ch in (b"j", b"J"):
return "down"
if ch in (b"k", b"K"):
return "up"
if ch in (b"q", b"Q"):
return "cancel"
if b == 27: # ESC or arrow-key prefix
if _sel.select([fd], [], [], 0.1)[0]:
nxt = os.read(fd, 1)
if nxt == b"[" and _sel.select([fd], [], [], 0.1)[0]:
arr = os.read(fd, 1)
if arr == b"A":
return "up"
if arr == b"B":
return "down"
if arr == b"C":
return "right"
if arr == b"D":
return "left"
# Not an arrow key — drain the rest of the CSI sequence so
# bytes like "0;1R" from a CPR (ESC[row;colR) don't leak into
# the next read or the prompt buffer as literal characters.
# The VT/xterm spec defines 0x400x7E as valid CSI final bytes.
while arr and not (0x40 <= arr[0] <= 0x7E):
if not _sel.select([fd], [], [], 0)[0]:
break
arr = os.read(fd, 1)
return "cancel"
return "ignore"
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old) # type: ignore[attr-defined]
def read_key_windows(*, also_cancel: tuple[bytes, ...] = ()) -> str:
"""Read one logical keypress on Windows; return a normalised key name.
Possible return values: ``"up"``, ``"down"``, ``"enter"``,
``"cancel"``, ``"tab"``, ``"right"``, ``"left"``, ``"eof"``,
``"ignore"``.
``also_cancel`` treats additional single-byte keys as ``"cancel"``.
"""
import msvcrt # type: ignore[import,attr-defined]
ch = msvcrt.getch() # type: ignore[attr-defined]
if ch in (b"\x03", b"\x1b") or ch in also_cancel:
return "cancel"
if ch in (b"\r", b"\n", b" "):
return "enter"
if ch == b"\t":
return "tab"
if ch in (b"j", b"J"):
return "down"
if ch in (b"k", b"K"):
return "up"
if ch in (b"q", b"Q"):
return "cancel"
if ch in (b"\xe0", b"\x00"):
ch2 = msvcrt.getch() # type: ignore[attr-defined]
if ch2 == b"H":
return "up"
if ch2 == b"P":
return "down"
if ch2 == b"M":
return "right"
if ch2 == b"K":
return "left"
return "ignore"
return "ignore"
__all__ = ["flush_stdin_unix", "read_key_unix", "read_key_windows", "restore_stdin_terminal"]
@@ -0,0 +1,41 @@
"""Shared Rich loaders for interactive-shell LLM calls.
A quiet, dim spinner shows that an LLM call is in flight. Centralised so
every LLM-backed surface in the interactive shell (``cli_agent``,
``follow_up``) shares the same look.
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from rich.console import Console
from platform.terminal.theme import SECONDARY
# Quiet, secondary-colour spinner — less visual noise than a bright accent.
_LOADER_COLOR = SECONDARY
_LOADER_SPINNER = "dots"
DEFAULT_LOADER_LABEL = "thinking"
@contextmanager
def llm_loader(console: Console, label: str = DEFAULT_LOADER_LABEL) -> Iterator[None]:
"""Show a dim spinner while an LLM call is in flight.
On non-terminal consoles (CI, captured output, piped stdout), the spinner is
skipped so captured logs stay clean — the wrapped call still runs unchanged.
"""
if not console.is_terminal:
yield
return
console.print()
text = f"[{_LOADER_COLOR}]{label}…[/{_LOADER_COLOR}]"
with console.status(text, spinner=_LOADER_SPINNER, spinner_style=_LOADER_COLOR):
yield
__all__ = ["DEFAULT_LOADER_LABEL", "llm_loader"]
@@ -0,0 +1,256 @@
"""REPL TTY plumbing: buffered print helpers and table factory.
Keeps cursor at column zero and normalises line endings under prompt_toolkit's
patch_stdout so Rich tables and JSON don't render as diagonal blocks.
Domain-specific table renderers live in :mod:`tables`.
"""
from __future__ import annotations
import io
import shutil
import sys
from collections.abc import Callable
from contextvars import ContextVar
from typing import Any
from rich import box
from rich.console import Console
from rich.markup import escape
from rich.table import Table
import platform.terminal.theme as ui_theme
_REPL_OUTPUT_PREPARED = ContextVar("_REPL_OUTPUT_PREPARED", default=False)
def _repl_output_already_prepared() -> bool:
"""Whether current call stack already prepared the TTY for Rich output."""
return _REPL_OUTPUT_PREPARED.get()
def _console_print_prepared(console: Console, *objects: Any, **kwargs: Any) -> None:
token = _REPL_OUTPUT_PREPARED.set(True)
try:
console.print(*objects, **kwargs)
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def _repl_table_width(console: Console) -> int:
"""Best-effort terminal width for Rich tables after inline menu I/O."""
term_cols = shutil.get_terminal_size(fallback=(80, 24)).columns
# Keep one safety column to avoid right-edge auto-wrap artifacts in some
# terminals (first-char clipping / duplicate right border when a row lands
# exactly on the terminal width).
return max(40, min(console.width, term_cols) - 1)
def _prepare_tty_for_rich(console: Console) -> int:
"""Return the width Rich should render at.
prepare_repl_output_line() (which writes \\r\\n) is intentionally NOT called
here. Under patch_stdout(raw=True), that extra newline causes the bottom
toolbar text to flush into the output stream before the table renders. Slash
commands start after the user presses Enter, so the cursor is already on a
fresh line; no extra line-feed is needed.
"""
return _repl_table_width(console)
def _normalize_repl_line_endings(text: str) -> str:
"""Convert Rich output to ``\\r\\n`` so each line starts at column zero."""
return text.replace("\r\n", "\n").replace("\n", "\r\n")
def _write_repl_tty_buffered(
*,
width: int,
leading_blank: bool,
render_to_buffer: Callable[[Console], None],
) -> None:
"""Render Rich output to a buffer and write it in one TTY-safe stdout call."""
buf = io.StringIO()
buf_console = Console(
file=buf,
force_terminal=True,
highlight=False,
width=width,
)
render_to_buffer(buf_console)
rendered = _normalize_repl_line_endings(buf.getvalue())
if leading_blank:
rendered = "\r\n" + rendered
token = _REPL_OUTPUT_PREPARED.set(True)
try:
sys.stdout.write(rendered)
sys.stdout.flush()
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def print_repl_table(console: Console, table: Table, *, width: int | None = None) -> None:
"""Print a Rich table using REPL-safe TTY width.
When the console writes to sys.stdout (the real REPL path), tables are
rendered into a string buffer first and written in a single sys.stdout.write
call with explicit \\r\\n line endings. This prevents the diagonal-render
artifact that occurs under prompt_toolkit's patch_stdout: each table row is
a separate Rich write, and if the terminal or proxy does not convert \\n to
\\r\\n, every row starts where the previous one ended instead of column zero.
When the console writes to a non-TTY stdout (piped output) or to a
different file (e.g. a StringIO in tests), the normal console.print path
is used — preserving the caller's color_system and avoiding ANSI pollution
in piped output.
"""
leading_blank = width is None
width = width if width is not None else _prepare_tty_for_rich(console)
if console.file is sys.stdout and sys.stdout.isatty():
_write_repl_tty_buffered(
width=width,
leading_blank=leading_blank,
render_to_buffer=lambda buf_console: buf_console.print(table),
)
else:
if leading_blank:
_console_print_prepared(console)
_console_print_prepared(console, table, width=width)
def print_repl_json(console: Console, json_str: str) -> None:
"""Print JSON via Rich using REPL-safe \\r\\n line endings.
Mirrors the buffered-write approach in :func:`print_repl_table` to prevent
the diagonal-render artifact under prompt_toolkit's patch_stdout: bare
``\\n`` from Rich does not imply a carriage-return, so each JSON line would
start at the column where the previous one ended. Rendering to a buffer
and normalising to ``\\r\\n`` ensures every line begins at column zero.
The leading blank is included in the same write call to avoid a stale CPR
sequence being left in stdin by a prompt_toolkit toolbar flush.
"""
width = _prepare_tty_for_rich(console)
if console.file is sys.stdout and sys.stdout.isatty():
_write_repl_tty_buffered(
width=width,
leading_blank=True,
render_to_buffer=lambda buf_console: buf_console.print_json(json_str),
)
else:
token = _REPL_OUTPUT_PREPARED.set(True)
try:
console.print_json(json_str)
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def repl_print(console: Console, *objects: Any, **kwargs: Any) -> None:
"""Print via Rich after resetting the TTY column (inline-menu safe)."""
from surfaces.interactive_shell.ui.components.choice_menu import prepare_repl_output_line
prepare_repl_output_line()
_console_print_prepared(console, *objects, **kwargs)
def _repl_write_buffer(rendered: str) -> None:
"""Flush pre-rendered Rich output with CRLF line endings (patch_stdout safe)."""
from surfaces.interactive_shell.ui.components.cpr_stdin import strip_cpr_escape_sequences
normalized = strip_cpr_escape_sequences(rendered.replace("\r\n", "\n").replace("\n", "\r\n"))
token = _REPL_OUTPUT_PREPARED.set(True)
try:
sys.stdout.write(normalized)
sys.stdout.flush()
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def repl_clear_screen() -> None:
"""Clear the terminal scrollback when the REPL runs under patch_stdout."""
if not sys.stdout.isatty():
return
sys.stdout.write("\x1b[2J\x1b[H")
sys.stdout.flush()
def _theme_notice_line(theme_notice: str) -> str:
"""REPL-safe ``theme set: <name>`` using the active palette (not stale imports)."""
return (
f"{ui_theme.HIGHLIGHT_ANSI}theme set: {escape(theme_notice)}{ui_theme.ANSI_RESET}\r\n\r\n"
)
def repl_render_launch_poster(
console: Console,
*,
session: object = None,
theme_notice: str | None = None,
) -> None:
"""Render splash + welcome panel using REPL-safe CRLF writes."""
from surfaces.interactive_shell.ui import banner as banner_module
if console.file is sys.stdout and sys.stdout.isatty():
width = _prepare_tty_for_rich(console)
buf = io.StringIO()
buf_console = Console(
file=buf,
force_terminal=True,
highlight=False,
color_system="truecolor",
legacy_windows=False,
width=width,
)
banner_module.render_splash(buf_console, first_run=False)
banner_module.render_ready_box(buf_console, session=session)
prefix = _theme_notice_line(theme_notice) if theme_notice else ""
_repl_write_buffer(prefix + buf.getvalue())
return
if theme_notice:
_console_print_prepared(
console,
f"[{ui_theme.HIGHLIGHT}]theme set:[/] {escape(theme_notice)}",
)
banner_module.render_splash(console, first_run=False)
banner_module.render_ready_box(console, session=session)
def refresh_welcome_poster(
console: Console,
*,
session: object = None,
theme_notice: str | None = None,
) -> None:
"""Clear scrollback and redraw splash art + welcome panel with the active theme."""
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
repl_clear_screen()
# ``repl_clear_screen`` can trigger a toolbar DSR/CPR exchange; drain before writing.
drain_stale_cpr_bytes()
repl_render_launch_poster(console, session=session, theme_notice=theme_notice)
def repl_table(**kwargs: Any) -> Table:
"""Minimal outer borders — closer to Claude Code than full ASCII grids."""
opts: dict[str, Any] = {
"box": box.MINIMAL_HEAVY_HEAD,
"show_edge": False,
"pad_edge": False,
"title_justify": "left",
}
opts.update(kwargs)
return Table(**opts)
__all__ = [
"_repl_output_already_prepared",
"_repl_table_width",
"print_repl_json",
"print_repl_table",
"refresh_welcome_poster",
"repl_clear_screen",
"repl_print",
"repl_render_launch_poster",
"repl_table",
]

Some files were not shown because too many files have changed in this diff Show More