diff --git a/AGENTS.md b/AGENTS.md index 6521d20..e3248f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **Deep review for large changes**: When a change substantially affects architecture, runtime behavior, user flows, persistence, safety, or multiple modules, perform an additional deep review before considering the work complete. - **Human confirmation for TUI changes**: Any TUI layout or interaction-logic change requires a second human confirmation before it is considered ready. +- **Human confirmation for slash-command paths**: Any change that adds, removes, renames, reroutes, or alters the behavior of a slash command (`/...`) — across the registry, router, in-process REPL, daemon REPL, `command_execute`, completion, and rendering — requires a second human confirmation before it is considered ready. This applies especially to user-experience-facing behavior (dispatch, prompts, confirmations, output, browser/dashboard launches, and error/recovery messaging), which must never be shipped on a single pass. - **Design goal check**: Verify that the implementation actually achieves the intended design goal and is not just a local patch. - **Optimality check**: Evaluate whether the solution is the simplest robust design, avoids unnecessary abstractions, and fits the existing architecture. - **Regression impact check**: Inspect affected modules and user journeys for logic bugs, degraded UX, broken compatibility, slower feedback, weaker diagnostics, or worse failure recovery. diff --git a/README.md b/README.md index 397c5e5..995143f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ### News +- **2026-07-16**: **LeapBoard** — general-purpose monitoring web dashboard (Watch→Finding + Server-Driven UI); `/board` entry, live session analysis, and finance/sentiment/research templates. - **2026-07-16**: v0.0.4 released — protocol-driven recovery coordinator with budget-constrained strategies, unified error classification, checkpoint-based cross-turn resumption, structured audit trail, and tool execution idempotency ledger. - **2026-07-15**: v0.0.3 released — layered YAML config, Path/Profile/Cache layouts, encrypted secret refs, DuckDB cache indexing, `leap config`, and robustness hardening. - **2026-07-15**: v0.0.2 released — TUI, Gateway/App Connector, Hub sync, Scheduler, Workflow Copilot, and runtime hardening. @@ -110,26 +111,44 @@ uv run leap --help ### 2. Configure Your LLM -If you already have an API key, base URL, and model name, save them through the unified config command: +Launch the interactive TUI and configure your provider with the built-in `/config` command. It stores the API key in the local secret vault and writes only a `secret://` reference into durable config: ```bash -leap config llm set \ - --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 \ - --model qwen3.7-plus \ - --ask-api-key +leap +``` + +Inside the TUI (subcommands, keys, and values all auto-complete as you type): + +```text +/config llm set --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --model qwen3.7-plus --api-key sk-... +/config show llm.model # verify the model and base URL +/config show llm.api_key # confirm the key is stored (shown masked, e.g. ***abc) ``` -Paste your API key when prompted. LeapFlow stores the key in the local secret vault and writes only a `secret://` reference into durable config. +Changes hot-reload the active session when possible, so you can start chatting right away. + +
+More: configure from the shell (CLI) -For scripts or CI, pass the key explicitly through the same config control plane: +The same control plane is available as `leap config` for terminals, scripts, or CI. Use `--ask-api-key` for a secure masked prompt, or `--api-key` to pass it explicitly: ```bash +# Interactive, secure key prompt: +leap config llm set \ + --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 \ + --model qwen3.7-plus \ + --ask-api-key + +# Non-interactive (scripts/CI): leap config llm set \ --base-url https://api.openai.com/v1 \ --model gpt-4o \ --api-key "$OPENAI_API_KEY" ``` +LeapFlow stores the key in the local secret vault and writes only a `secret://` reference into durable config. +
+ ### 3. Install Execution Backend (macOS only) The native execution backend enables screen capture, accessibility tree access, and input injection. Skip this step if you only want to chat or explore with `--mock-host`. @@ -511,6 +530,89 @@ For deployment environments, provision platform credentials through the same gat --- +## LeapBoard — Monitoring Dashboard + +> **Signals into insight.** + +**LeapBoard** extends LeapFlow's Observe/Orient boundary into a general-purpose, always-on monitoring surface. Launch independent, persistent **watches** — for financial-market anomalies, public-opinion (sentiment) shifts, new research papers, or your own live conversation — and each one continuously observes its source, filters signal from noise, and surfaces **findings** (typed, scored, with evidence) onto a real-time web board. + +It is domain-neutral by design: finance, sentiment, research, and session analysis differ only by a small YAML template and their finding payload — never by branching in core code. + +### How it works + +``` +Watch (leapd-hosted) ── observe → SNR filter → score → Finding ──▶ NotificationBus + ▲ │ push + └── trigger / manual refresh / session batch / salience ───────┘ + YAML template ─▶ ViewSpec (validated) ─▶ Web board (SDUI) ◀── WebSocket +``` + +- **Continuous observation** — a board starts as a `Watch` in `leapd`. Scheduler triggers, manual `/board refresh`, and session-analysis batch thresholds all run the same observe→finding cycle; new findings are persisted, severity-gated, and pushed to browsers over WebSocket. +- **Refresh model** — the session board re-analyzes as the conversation accumulates turns, when written workspace artifacts change, or on manual `/board refresh`; `/board pause`/`stop` halt re-analysis until resumed. The chosen **template** only changes rendering, never what is analyzed (always the current session). +- **Server-Driven UI** — each scenario is authored as a **YAML template** compiled into a validated **ViewSpec** over a fixed component catalog (cards, tables, charts, timelines, gauges, story panels…). Interactive components talk back through a bidirectional action protocol; unknown component types degrade gracefully, and bespoke visuals use a `Custom` escape hatch. The board never renders arbitrary HTML/JS. +- **View client** — the board connects to `leapd` like the TUI does, with no privileged coupling. The web server is optional (`aiohttp`) and degrades with a clear install hint when absent. + +### Install & enable + +```bash +pip install 'leapflow[dashboard]' # adds the optional aiohttp web server +``` + +The board binds to `127.0.0.1` with a per-session access token. Tune it through `leap config`: + +| Key | Default | Description | +|-----|---------|-------------| +| `dashboard.enabled` | `true` | Enable the local monitoring web board | +| `dashboard.bind` | `127.0.0.1` | Bind address (keep loopback) | +| `dashboard.port` | `8765` | Web server port | +| `dashboard.auto_open` | `true` | Open the default browser automatically | +| `monitor.session_batch_turns` | `6` | Re-analyze the session every N new turns | + +### Usage + +**Analyze the current session** — from the TUI or shell. LeapBoard always analyzes the *current session*; a **template** is just the lens it renders through (`generic` is the default; an unrecognized name is rejected rather than silently coerced): + +```text +/board open the session board with the default (generic) template +/board finance render the same session analysis through the finance lens +/board status show watch state, recent findings, and available templates +/board refresh re-analyze now (append an id to target a specific watch) +/board pause|resume pause / resume analysis (append an id to target a watch) +/board stop stop the current session, or `/board stop ` a specific watch +``` + +```bash +leap board # ensure the server is running and open the browser +leap board --no-open # print the URL instead of opening a browser +``` + +Executing any open entry auto-launches your default browser at the token-scoped local URL. Inside the browser, the language switcher supports English, Chinese, French, Spanish, Arabic, and Russian. + +**Templates (view lenses)** — templates are the single view dimension, and you can add your own with one command: + +```text +/board templates list built-in and custom templates +/board templates add ./my.yaml validate and register a custom lens (profile-scoped) +/board templates show show a template's source and title +/board templates remove remove a custom template (builtins stay) +``` + +A custom template is any SDUI YAML file. `add` validates it (rejecting templates that will not compile), copies it into `profiles//dashboard/templates/`, and makes it immediately openable via `/board `. Custom templates override built-ins of the same name. + +**Session context coverage** — the session board watches more than chat text. It also scans current-session `file_write` tool results and, when the path is inside the workspace and safe to read without approval, includes a capped/redacted file excerpt in the analysis. The **Observation status** panel shows refresh reason, coverage, observed targets, included/skipped file artifacts, and missing context notes. + +**Natural language** — just ask ("analyze this conversation", "show the finance lens"); the agent opens the board with the right template when the runtime can safely map the request. + +### Showcase + +- **Session analysis (default `generic`)** — a storyline, insights, decisions, action items, open questions, an entity map, artifact coverage, and suggested next prompts; refreshes automatically as the conversation grows or workspace artifacts change. +- **Finance / Sentiment / Research lenses** — the same session analysis, reframed: finance surfaces calls, an execution checklist, and exposures; sentiment surfaces the narrative pulse and themes; research surfaces open questions, evidence, and follow-ups. +- **Custom lenses** — bring your own YAML template and open it with `/board `; it renders the current session through your layout with zero code changes. + +> Adding a lens = one YAML template dropped in (or `/board templates add`). The board core stays untouched — one analysis target, no domain branching, no hardcoded layouts. + +--- + ## Safety & Approval LeapFlow enforces a **layered safety architecture** that balances autonomy with human oversight. The goal is minimal interruption — the agent asks for permission only when an action carries real consequences. @@ -768,6 +870,8 @@ uv run pytest -k "test_world_model" -q # By keyword | `learning/` | Skill distillation, parameterization, and maturity lifecycle | | `skills/` | Skill library, runtime execution, and self-evolution (Loop γ) | | `copilot/` | Workflow-level next-step prediction and proactive suggestion | +| `monitor/` | Domain-neutral Watch→Finding monitoring runtime (leapd-hosted scheduler, findings store) | +| `dashboard/` | LeapBoard — Server-Driven UI web board (ViewSpec catalog, YAML templates, WebSocket fan-out) | | `analysis/` | Six-layer denoising pipeline for trajectory refinement | | `engine/` | Session orchestration and ReAct execution loop | | `memory/` | Three-tier event-driven memory (working → episodic → long-term) | diff --git a/pyproject.toml b/pyproject.toml index 1d7bc71..8268ba7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dev = [ "ruff>=0.4.0", ] hub = ["modelscope-hub>=0.1.0"] +dashboard = ["aiohttp>=3.9"] [project.scripts] leap = "leapflow.__main__:main" @@ -56,6 +57,8 @@ include = ["leapflow*"] [tool.setuptools.package-data] "leapflow.gateway.action_packs" = ["*.yaml"] +"leapflow.dashboard.templates" = ["*.yaml"] +"leapflow.dashboard.static" = ["*"] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index 28b11d4..3ff59b2 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -175,6 +175,23 @@ def _daemon_enabled(args: argparse.Namespace) -> bool: return raw not in {"0", "false", "no", "off"} +def _suggest_known_command(token: str, known_commands: set[str]) -> str | None: + """Return the closest known command when a token looks like a typo, else None. + + Uses a conservative similarity cutoff so genuine free-text prompts (e.g. + ``leap what is a daemon``) still route to chat, while near-miss command + typos (e.g. ``deamon`` -> ``daemon``) are caught and surfaced as a + suggestion instead of silently becoming an LLM chat turn that also spawns a + daemon. + """ + import difflib + + matches = difflib.get_close_matches( + token.lower(), sorted(known_commands), n=1, cutoff=0.82 + ) + return matches[0] if matches else None + + def main(argv: list[str] | None = None) -> int: common = argparse.ArgumentParser(add_help=False) common.add_argument( @@ -255,6 +272,15 @@ def main(argv: list[str] | None = None) -> int: serve_parser = daemon_sub.add_parser("serve", help=argparse.SUPPRESS) serve_parser.add_argument("--internal", action="store_true", help=argparse.SUPPRESS) + # leap board (LeapBoard monitoring dashboard) + dashboard_parser = subparsers.add_parser("board", help="Open the LeapBoard session analysis board") + dashboard_parser.add_argument("template", nargs="?", default="", help="Template lens to render (default: generic)") + dashboard_parser.add_argument("--serve", action="store_true", help=argparse.SUPPRESS) + dashboard_parser.add_argument("--token", default="", help=argparse.SUPPRESS) + dashboard_parser.add_argument("--port", type=int, default=0, help="Override the dashboard port") + dashboard_parser.add_argument("--bind", default="", help="Override the dashboard bind address") + dashboard_parser.add_argument("--no-open", action="store_true", help="Print the URL instead of opening a browser") + # leap config config_parser = subparsers.add_parser("config", help="View and update LeapFlow configuration") config_sub = config_parser.add_subparsers(dest="config_action") @@ -306,7 +332,7 @@ def main(argv: list[str] | None = None) -> int: # ── Pre-parse: detect if first non-flag arg is a known subcommand ── # If not, treat everything non-flag as a chat prompt. - known_commands = {"teach", "run", "skills", "relearn", "host", "daemon", "config"} + known_commands = {"teach", "run", "skills", "relearn", "host", "daemon", "config", "board"} effective_argv = list(argv) if argv is not None else sys.argv[1:] # Find first non-flag argument, skipping values owned by global options. @@ -327,6 +353,25 @@ def main(argv: list[str] | None = None) -> int: break if first_pos is not None and effective_argv[first_pos] not in known_commands: + first_token = effective_argv[first_pos] + non_flag_tokens = [tok for tok in effective_argv if not tok.startswith("-")] + # A short, command-like invocation whose first word is a near-miss of a + # known command is almost certainly a typo (e.g. `leap deamon status`). + # Surface a suggestion instead of silently spawning a daemon + LLM chat. + if len(non_flag_tokens) <= 3: + suggestion = _suggest_known_command(first_token, known_commands) + if suggestion is not None: + # Replace only the mistyped token so preceding global flags + # (e.g. --profile dev) and trailing args are preserved. + corrected_argv = list(effective_argv) + corrected_argv[first_pos] = suggestion + corrected = " ".join(["leap", *corrected_argv]) + sys.stderr.write( + f"leap: '{first_token}' is not a leap command. " + f"Did you mean '{suggestion}'?\n" + f"Try: {corrected}\n" + ) + return 2 # Collect all non-option prompt tokens while preserving global option values. flags: list[str] = [] prompt_words = [] @@ -382,6 +427,11 @@ def main(argv: list[str] | None = None) -> int: from leapflow.cli.commands.daemon import cmd_daemon return cmd_daemon(args) + # Dashboard is a view client (connects to leapd); no Context initialization + if args.command == "board": + from leapflow.cli.commands.dashboard import cmd_dashboard + return cmd_dashboard(args) + try: if args.command in {"interactive", "chat"} and _daemon_enabled(args): return asyncio.run(_async_daemon_main(args)) diff --git a/src/leapflow/cli/commands/dashboard.py b/src/leapflow/cli/commands/dashboard.py new file mode 100644 index 0000000..de21901 --- /dev/null +++ b/src/leapflow/cli/commands/dashboard.py @@ -0,0 +1,70 @@ +"""`leap board` — open or serve the LeapBoard monitoring web dashboard. + +Two modes: +- default (client): ensure a dashboard server is running, then open the default + browser at the requested view. Returns immediately; the server runs detached. +- ``--serve`` (server): run the aiohttp view server in the foreground. Used by + the launcher spawn and for a dedicated dashboard terminal. + +The dashboard is a view client: it connects to leapd, so it does not initialize +its own Context. +""" + +from __future__ import annotations + +import argparse +import asyncio + +from leapflow.config import load_config +from leapflow.dashboard import launcher + +_DEP_HINT = ( + "The dashboard web server requires the optional 'aiohttp' dependency.\n" + "Install it with: pip install 'leapflow[dashboard]'" +) + + +def cmd_dashboard(args: argparse.Namespace) -> int: + """Entry point for the ``leap board`` subcommand.""" + settings = load_config() + + if not launcher.aiohttp_available(): + print(_DEP_HINT) + return 1 + + if getattr(args, "serve", False): + return _serve(args, settings) + return _open(args, settings) + + +def _serve(args: argparse.Namespace, settings: object) -> int: + from leapflow.dashboard.server import run_server + + token = getattr(args, "token", "") or launcher.generate_token() + bind = getattr(args, "bind", "") or settings.dashboard_bind + port = getattr(args, "port", 0) or settings.dashboard_port + try: + return asyncio.run(run_server(settings, token=token, bind=bind, port=port)) + except KeyboardInterrupt: + return 130 + + +def _open(args: argparse.Namespace, settings: object) -> int: + try: + state = launcher.ensure_server(settings) + except RuntimeError as exc: + print(f"dashboard: {exc}") + return 1 + + template = getattr(args, "template", "") or "" + url = launcher.build_view_url(state["bind"], state["port"], state["token"], template=template) + + auto_open = bool(getattr(settings, "dashboard_auto_open", True)) and not getattr(args, "no_open", False) + if auto_open and launcher.open_in_browser(url): + print(f"Opened dashboard in your browser: {url}") + else: + print(f"Dashboard ready: {url}") + return 0 + + +__all__ = ["cmd_dashboard"] diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 289dac8..54544f8 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -30,6 +30,8 @@ _last_hint: Optional["PredictionCandidate"] = None +_WATCH_EXIT_ACTIVE_STATES = frozenset({"armed", "watching", "due", "confirming", "executing"}) + def _is_app_command(canonical: str) -> bool: """Return true only for `/app` or `/app ...`, not `/apple`.""" @@ -51,6 +53,7 @@ async def _prompt_stop_daemon_on_exit( "leapd runs in the background; stop/restart it after reinstalling LeapFlow " "to load new code." ) + await _stop_board_watches_on_exit(client, console) connected_clients = daemon_status.get("connected_clients") other_clients = 0 try: @@ -71,25 +74,96 @@ async def _prompt_stop_daemon_on_exit( from leapflow.daemon.lifecycle import stop_daemon runtime_dir = settings.runtime_dir - console.system(f"Stopping leapd (pid={pid})...") + console.system(f"Stopping leapd (pid={pid}); requesting graceful shutdown...") graceful_requested = False try: - await asyncio.wait_for(client.shutdown(), timeout=2.0) + await asyncio.wait_for(client.shutdown(), timeout=3.0) graceful_requested = True except Exception: graceful_requested = False + # The user confirmed the stop, so escalate all the way to SIGKILL if needed. + # Patience over surprise: use generous windows and narrate every step so the + # wait is transparent rather than a silent stall. result = await asyncio.to_thread( stop_daemon, runtime_dir, - timeout_s=5.0, - grace_timeout_s=1.0 if graceful_requested else 0.0, + timeout_s=8.0, + force=True, + grace_timeout_s=3.0 if graceful_requested else 0.0, + force_timeout_s=6.0, + on_progress=console.system, ) if result.stopped: - console.system("leapd stopped.") + suffix = " (forced with SIGKILL)" if result.forced else "" + console.system(f"leapd stopped{suffix}.") else: console.warning( - f"leapd did not stop within the exit window (pid={result.pid}). " - "Run `leap daemon stop --force` if it remains unhealthy." + f"leapd could not be stopped even after SIGKILL (pid={result.pid}). " + "It may be blocked on the OS (e.g. disk I/O); run " + f"`kill -9 {result.pid}` if it stays unhealthy." + ) + + +async def _stop_board_watches_on_exit(client: "DaemonClient", console: Any) -> None: + """Stop every active board watch before exiting so none linger in leapd. + + LeapBoard watches are tied to the interactive session, so leaving the TUI + tears them down; a brief line tells the user what was stopped. + """ + try: + watches = await asyncio.wait_for(client.watch_list(), timeout=1.5) + except Exception: + return + active = [w for w in watches if isinstance(w, dict) and _is_active_watch(w)] + watch_ids = [wid for wid in (str(w.get("watch_id", "")) for w in active) if wid] + if not watch_ids: + return + # Stop concurrently so exit is bounded by a single timeout, not the sum of + # one per watch; keep per-watch diagnostics for any that fail. + results = await asyncio.gather( + *(asyncio.wait_for(client.watch_stop(wid), timeout=1.0) for wid in watch_ids), + return_exceptions=True, + ) + stopped = 0 + for wid, result in zip(watch_ids, results): + if isinstance(result, Exception): + logger.debug("interactive: failed to stop board watch %s", wid, exc_info=result) + else: + stopped += 1 + if stopped: + console.system(f"Stopped {stopped} board watch(es) on exit.") + + +def _is_active_watch(watch: dict[str, Any]) -> bool: + return str(watch.get("state", "")) in _WATCH_EXIT_ACTIVE_STATES + + +async def _open_dashboard_view(settings: Any, console: Any, payload: dict[str, Any]) -> None: + """Open the web dashboard on the user's machine (client-side). + + Shared by both the in-process and daemon-backed REPLs so a single owner + handles server discovery, URL assembly, and the browser-open UX. + """ + from leapflow.dashboard import launcher + + template = str(payload.get("template") or "") + url = payload.get("url") + if not url: + try: + state = await asyncio.to_thread(launcher.ensure_server, settings) + except Exception as exc: + console.warning(f"Could not start the dashboard server: {exc}") + return + url = launcher.build_view_url( + state["bind"], state["port"], state["token"], template=template, + ) + if launcher.open_in_browser(url): + console.system(f"Opened dashboard in your browser: {url}") + else: + console.system(f"Dashboard ready (open manually): {url}") + if payload.get("watch_id"): + console.system( + "Observing the current session; analysis streams to the board as it completes." ) @@ -267,6 +341,7 @@ async def cmd_interactive(ctx: "Context", *, resume_id: Optional[str] = None) -> from leapflow.config_service import ConfigService from leapflow.cli.commands.router import CommandRouter, render_command_result from leapflow.cli.commands.slash_handlers import ( + command_execute, handle_status, handle_tool, handle_usage, @@ -275,6 +350,7 @@ async def cmd_interactive(ctx: "Context", *, resume_id: Optional[str] = None) -> handle_clear, handle_gateway, handle_app, + render_command_payload, ) from leapflow.utils.terminal_io import TerminalIOProvider from leapflow.engine.session import SessionMode @@ -674,6 +750,26 @@ async def handle_input(text: str) -> None: ) return + # Registered engine command with no in-process fast path (e.g. + # /board): route through the shared command_execute contract so a + # recognized slash command never leaks into the LLM chat stream. + # Client-local commands are handled above or via task-control. + if not cmd_def.client_local: + try: + payload = await command_execute(ctx, canonical, cmd_args) + except Exception as exc: + console.warning(f"/{canonical} failed: {exc}") + return + if str(payload.get("view")) == "dashboard": + if payload.get("mode") == "open": + await _open_dashboard_view(ctx.settings, console, payload) + else: + render_command_payload(console, payload) + return + else: + render_command_payload(console, payload) + return + if _learning: ctx.imitation.end_control_input() @@ -847,13 +943,10 @@ async def cmd_interactive_daemon( from leapflow.cli.banner import display_rich_banner from leapflow.cli.commands.registry import completion_entries from leapflow.config_service import ConfigService - from leapflow.cli.commands.router import CommandRouter, render_command_result + from leapflow.cli.commands.router import CommandRouter from leapflow.cli.commands.slash_handlers import ( render_app_payload, render_command_payload, - render_model_payload, - render_tool_payload, - render_usage_payload, ) from leapflow.cli.tui_app import ( LeapApp, @@ -1121,6 +1214,10 @@ async def _stream_response( # Session mode tracking for teaching — daemon-side state mirrored here daemon_session_mode = "idle" + async def _open_dashboard(payload: dict) -> None: + """Open the web dashboard on the user's machine (client-side).""" + await _open_dashboard_view(settings, console, payload) + async def handle_input(text: str) -> None: nonlocal daemon_session_mode @@ -1184,6 +1281,13 @@ async def handle_input(text: str) -> None: _apply_daemon_runtime_metadata({"host_backend": payload["result"]}) _update_status() + if str(payload.get("view")) == "dashboard": + if payload.get("mode") == "open": + await _open_dashboard(payload) + else: + render_command_payload(console, payload) + return + # Render: app-specific views use app renderer, others use generic view = str(payload.get("view") or "") if view in ("list", "guide", "connect", "disconnect", "remove", "events", "actions") and "result" in payload: @@ -1342,11 +1446,22 @@ async def _notification_listener() -> None: """Subscribe to daemon notifications and render them in TUI.""" nonlocal daemon_session_mode from leapflow.daemon.client import DaemonUnavailableError + + async def _refresh_watch_count() -> None: + try: + watches = await bridge.client.watch_list() + except Exception: + return + active = sum(1 for w in watches if w.get("state") in ("armed", "watching")) + status.update_monitor_counts(watches=active) + app.invalidate() + while True: try: # Reset stale state on each (re)connection attempt status.distill_phase = "" status.distill_progress = 0.0 + await _refresh_watch_count() async for event in bridge.client.subscribe_notifications(): event_type = event.get("event_type", "") payload = event.get("payload") or {} @@ -1381,6 +1496,15 @@ async def _notification_listener() -> None: else: console.system("Distillation complete — no skills produced (insufficient signal)") app.invalidate() + elif event_type == "monitor.finding": + console.monitor_card(payload) + if str(payload.get("severity")) == "alert": + status.update_monitor_counts(alerts=status.alert_count + 1) + app.invalidate() + elif event_type == "watch.state": + await _refresh_watch_count() + elif event_type == "monitor.error": + logger.debug("monitor error notification: %s", payload) except (DaemonUnavailableError, OSError, asyncio.IncompleteReadError): await asyncio.sleep(3.0) except asyncio.CancelledError: diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index 130117d..ce6d287 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -132,6 +132,16 @@ def supports_runtime(self, runtime: CommandRuntime) -> bool: # Scheduler CommandDef("arm", "Schedule a skill for timed execution", "Scheduler", args_hint=" "), CommandDef("task", "List scheduled tasks", "Scheduler"), + + # Board & Monitors (LeapBoard) — one analysis target (current session), + # rendered through a selectable template lens. + CommandDef("board", "Analyze the current session; optionally pick a template lens", "Board", args_hint="[