Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2e2252a
Harden local control: macOS permission preflight, 429 backoff, CLI ou…
abonneth Jul 9, 2026
a01caa3
Surface a stop during channel setup as a startup failure
abonneth Jul 9, 2026
c8705a8
Address review: cap setup backoff under ready timeout, move platform …
abonneth Jul 9, 2026
b3ae4e0
Cancel local-bridge sessions at interpreter exit
abonneth Jul 9, 2026
6d69250
Lift holo-desktop hardening: kill switch, hai doctor, runaway budgets…
abonneth Jul 9, 2026
8a430e9
Cap desktop screenshot width at 1920 by default; fix kill-switch test…
abonneth Jul 10, 2026
6fae966
Arm the kill-switch watcher before bridge startup
abonneth Jul 10, 2026
31f90bc
Compose ScaledDesktopDriver in the desktop bridge
abonneth Jul 10, 2026
57b95d3
feat(local): scale and encode desktop screenshots at the source
abonneth Jul 13, 2026
1b78c5a
Match the screenshot_b64 driver command surface
abonneth Jul 13, 2026
fb13e7e
Guard the scaled driver import with the install hint
abonneth Jul 13, 2026
8d82af5
Pin hai-drivers 0.1.2 from PyPI
abonneth Jul 15, 2026
5ef3518
Merge remote-tracking branch 'origin/main' into antoine/desktop-bridg…
abonneth Jul 16, 2026
022fda3
Merge remote-tracking branch 'origin/antoine/desktop-bridge-scaling' …
abonneth Jul 16, 2026
d36a133
Refresh uv.lock after merging desktop-bridge-scaling
abonneth Jul 16, 2026
b65abcb
Stub the local driver module in tests so they run on headless CI
abonneth Jul 16, 2026
ddce165
Prompt for macOS permissions on the caller's thread before bridge sta…
abonneth Jul 16, 2026
4b35293
Back off on rate limits during channel recreation; surface a stopped …
abonneth Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ cli = [
"typer>=0.12,<1",
]
desktop = [
"hai-drivers[desktop]>=0.1.0",
"hai-drivers[desktop]>=0.1.2",
]
browser = [
"hai-drivers[web]>=0.1.0",
Expand Down
112 changes: 97 additions & 15 deletions src/hai_agents_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@

import typer
from rich.console import Console
from rich.table import Table
from rich.markup import escape
from rich.table import Column, Table

from hai_agents import Client, assert_request_under_limit, is_settled_session_status, wait_for_session
from hai_agents.core.api_error import ApiError
Expand All @@ -21,6 +22,7 @@
from hai_agents_common import credentials
from hai_agents_common.credentials import absolute_share_url, make_client
from hai_agents_common.jsonable import to_jsonable
from hai_agents_local import desktop as desktop_defaults

from . import auth, mcp_hosts

Expand Down Expand Up @@ -108,6 +110,25 @@ def logout() -> None:
console.print(f"Removed {credentials.API_KEY_VAR} from {path}." if path else "No stored key found.")


@app.command()
def doctor(ctx: typer.Context) -> None:
"""Diagnose this machine's hai setup: login, platform access, local browser/desktop. Read-only."""
from .doctor import run_checks

state = _state(ctx)
results = run_checks(api_key=state.api_key, base_url=state.base_url)
if state.json_output:
_print_json([{"name": r.name, "ok": r.ok, "detail": r.detail, "fix": r.fix} for r in results])
else:
for r in results:
mark = "[bold green]ok[/bold green]" if r.ok else "[bold red]fail[/bold red]"
console.print(f"{mark} [bold]{r.name}[/bold] {escape(r.detail)}")
if r.fix is not None:
console.print(f" [dim]fix:[/dim] {escape(r.fix)}")
if not all(r.ok for r in results):
raise typer.Exit(1)


@app.command()
def whoami(ctx: typer.Context) -> None:
"""Show the resolved endpoint and authentication status."""
Expand Down Expand Up @@ -225,7 +246,8 @@ def list_sessions(
_print_json(result)
return

table = Table("ID", "Status", "Agent", "Created")
# The ID column never shrinks: a truncated UUID cannot be pasted into follow-up commands.
table = Table(Column("ID", min_width=36, no_wrap=True), "Status", "Agent", "Created")
for item in result.items:
url = getattr(item, "agent_view_url", None)
id_cell = f"[link={url}]{item.id}[/link]" if url else item.id
Expand Down Expand Up @@ -330,7 +352,7 @@ def status(ctx: typer.Context, session_id: str = typer.Argument(...)) -> None:
if result.steps is not None:
table.add_row("Steps", str(result.steps))
if result.error:
table.add_row("Error", result.error)
table.add_row("Error", escape(result.error))
console.print(table)


Expand Down Expand Up @@ -425,7 +447,7 @@ def list_agents_command(
return
table = Table("Name", "Description")
for item in result.items:
table.add_row(item.name, _truncate(item.description))
table.add_row(item.name, escape(_truncate(item.description)))
console.print(table)


Expand Down Expand Up @@ -463,7 +485,7 @@ def list_skills_command(
return
table = Table("Name", "Description")
for item in result.items:
table.add_row(item.name, _truncate(item.description))
table.add_row(item.name, escape(_truncate(item.description)))
console.print(table)


Expand Down Expand Up @@ -586,11 +608,42 @@ def local_browser(
def local_desktop(
ctx: typer.Context,
session_id: str | None = typer.Option(None, "--session-id", help="Session id to serve. Generated when omitted."),
max_width: int = typer.Option(
desktop_defaults.DEFAULT_MAX_WIDTH, "--max-width", help="Cap screenshot width in pixels. 0 disables."
),
max_height: int = typer.Option(0, "--max-height", help="Cap screenshot height in pixels. 0 disables."),
image_format: str = typer.Option(
desktop_defaults.DEFAULT_IMAGE_FORMAT, "--image-format", help="Screenshot encoding: png, jpeg, or webp."
),
quality: int = typer.Option(
desktop_defaults.DEFAULT_QUALITY, "--quality", help="Encoding quality (1-100) for jpeg/webp."
),
) -> None:
"""Serve desktop commands on this machine's mouse, keyboard, and screen."""
from hai_agents_local import PyautoguiDesktopBridge

_run_bridge(_state(ctx), PyautoguiDesktopBridge, session_id)
if image_format not in ("png", "jpeg", "webp"):
_raise_cli_error(ValueError(f"--image-format must be png, jpeg, or webp; got {image_format!r}"))
_run_bridge(
_state(ctx),
PyautoguiDesktopBridge,
session_id,
max_width=max_width or None,
max_height=max_height or None,
image_format=image_format,
quality=quality,
)


@local_app.command("stop")
def local_stop() -> None:
"""Stop any in-flight local browser/desktop turn on this machine (same effect as double-Esc)."""
from hai_agents_local.killswitch import request_stop

request_stop()
console.print(
"Stop requested: local bridges on this machine will halt, and SDK-attached sessions will be cancelled."
)


def _run_bridge(state: AppState, bridge_type: type[LocalBridge], session_id: str | None, **options: Any) -> None:
Expand All @@ -604,12 +657,13 @@ def _run_bridge(state: AppState, bridge_type: type[LocalBridge], session_id: str
session_id=session_id,
**options,
)
except (RuntimeError, ValueError) as exc:
bridge.preflight()
except (RuntimeError, ValueError, PermissionError) as exc:
_raise_cli_error(exc)

console.print(
f"[bold]Local {bridge.environment_kind} bridge[/bold] serving session id "
f"[cyan]{bridge.session_id}[/cyan]. Press Ctrl-C to stop."
f"[cyan]{bridge.session_id}[/cyan]. Press Ctrl-C to stop, or run `hai local stop` from any terminal."
Comment thread
cursor[bot] marked this conversation as resolved.
)
console.print(
"[dim]Point a user_device environment at it: "
Expand All @@ -619,20 +673,47 @@ def _run_bridge(state: AppState, bridge_type: type[LocalBridge], session_id: str
asyncio.run(_serve_bridge(bridge))
except KeyboardInterrupt:
console.print("Stopped.")
return
except Exception as exc:
_raise_cli_error(exc)
if not bridge.ready.is_set():
_raise_cli_error(RuntimeError("the bridge was stopped during setup and never served"))


async def _serve_bridge(bridge: Any) -> None:
import signal

from hai_agents_local.killswitch import StopWatcher

loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, bridge.request_stop)
except (NotImplementedError, RuntimeError, ValueError):
pass
await bridge.run()
watcher = StopWatcher(lambda: loop.call_soon_threadsafe(bridge.request_stop))
listener = _arm_kill_switch(bridge)
try:
await bridge.run()
finally:
watcher.stop()
if listener is not None:
listener.stop()


def _arm_kill_switch(bridge: Any) -> Any:
"""Arm the double-Esc listener for desktop serving; a desktop turn steals focus, so Ctrl-C may be out of reach."""
if bridge.environment_kind != "desktop" or not (sys.stdin.isatty() and sys.stdout.isatty()):
return None
from hai_agents_local.killswitch import KILL_SWITCH_ARMED_HINT, KILL_SWITCH_UNAVAILABLE_HINT, arm_esc_listener

listener = arm_esc_listener()
if listener is not None:
console.print(f"[dim]{KILL_SWITCH_ARMED_HINT}[/dim]")
elif sys.platform == "darwin":
# A panic button that silently failed to arm is dangerous; always say so.
console.print(f"[yellow]{KILL_SWITCH_UNAVAILABLE_HINT}[/yellow]")
return listener


app.add_typer(sessions_app, name="sessions")
Expand Down Expand Up @@ -675,7 +756,7 @@ def _select_agent(state: AppState, client: Client) -> str:
if len(desc) > 80:
desc = desc[:77] + "..."
line = f" [bold]{i}[/bold]. {item.name}"
console.print(f"{line} [dim]{desc}[/dim]" if desc else line)
console.print(f"{line} [dim]{escape(desc)}[/dim]" if desc else line)
choice = typer.prompt("Agent number", type=int)
if not 1 <= choice <= len(agents):
_raise_cli_error(RuntimeError(f"Choice must be between 1 and {len(agents)}."))
Expand All @@ -695,7 +776,7 @@ def _print_run_result(result, json_output: bool, agent_view_url: str | None = No
console.print(f"[bold]Session:[/bold] {result.id}")
console.print(f"[bold]Status:[/bold] {_status_text(result.status)}")
if result.answer is not None:
console.print(result.answer)
console.print(escape(result.answer))


def _print_ack(action: str, json_output: bool) -> None:
Expand All @@ -717,10 +798,10 @@ def _print_watch_result(state: AppState, status_result, final) -> None:
console.print(f"[bold]Status:[/bold] {_status_text(status_result.status)}")
error = status_result.error or (final.error if final is not None else None)
if error:
console.print(f"[bold]Error:[/bold] {error}")
console.print(f"[bold]Error:[/bold] {escape(error)}")
answer = final.answer if final is not None else None
if answer is not None:
console.print(answer)
console.print(escape(answer))


def _status_text(status) -> str:
Expand All @@ -737,7 +818,7 @@ def _event_line(index: int, event) -> str:
event_type = getattr(event, "type", "Event")
data = getattr(event, "data", None)
detail = _truncate(json.dumps(to_jsonable(data), sort_keys=True), 120) if data is not None else ""
return f"[dim]{index:>4}[/dim] [cyan]{event_type}[/cyan]" + (f" {detail}" if detail else "")
return f"[dim]{index:>4}[/dim] [cyan]{event_type}[/cyan]" + (f" {escape(detail)}" if detail else "")


def _emit_watch_event(state: AppState, index: int, event) -> None:
Expand Down Expand Up @@ -776,5 +857,6 @@ def _raise_cli_error(exc: Exception) -> NoReturn:
message = f"API error {exc.status_code}: {message}"
else:
message = str(exc)
err_console.print(f"[red]Error:[/red] {message}")
# Escape: rich would otherwise eat brackets in messages, e.g. pip extras like hai-agents[browser].
err_console.print(f"[red]Error:[/red] {escape(message)}")
raise typer.Exit(1)
107 changes: 107 additions & 0 deletions src/hai_agents_cli/doctor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""`hai doctor`: read-only diagnostics for login, platform access, and local control, with fix-its."""

from __future__ import annotations

import importlib.util
import socket
import sys
from dataclasses import dataclass

from hai_agents_common import credentials

DEFAULT_CHROME_DEBUG_PORT = 9222


@dataclass(frozen=True)
class CheckResult:
name: str
ok: bool
detail: str
fix: str | None = None


def check_login(api_key: str | None) -> CheckResult:
if credentials.current_api_key(api_key):
return CheckResult("login", True, f"API key found ({credentials.source(api_key)})")
return CheckResult(
"login",
False,
"no API key configured",
fix=f"run `hai login`, set {credentials.API_KEY_VAR}, or pass --api-key",
)


def check_platform(api_key: str | None, base_url: str | None) -> CheckResult:
endpoint = credentials.resolve_base_url(base_url) or "(SDK default)"
try:
client = credentials.make_client(api_key=api_key, base_url=base_url)
client.agents.list_agents(page=1, size=1)
except Exception as exc:
return CheckResult(
"platform",
False,
f"cannot query {endpoint}: {exc}",
fix="check the key and network; `hai whoami` shows the resolved endpoint",
)
return CheckResult("platform", True, f"authenticated against {endpoint}")


def check_browser() -> CheckResult:
if importlib.util.find_spec("hai_drivers") is None or importlib.util.find_spec("hai_drivers.web") is None:
return CheckResult(
"browser", True, "local browser control not installed; add it with: pip install 'hai-agents[browser]'"
)
if _port_in_use(DEFAULT_CHROME_DEBUG_PORT):
detail = f"deps installed; a debuggable Chrome is listening on port {DEFAULT_CHROME_DEBUG_PORT} (will attach)"
else:
detail = f"deps installed; port {DEFAULT_CHROME_DEBUG_PORT} is free (a Chrome will be launched on demand)"
return CheckResult("browser", True, detail)


def check_desktop() -> CheckResult:
if importlib.util.find_spec("hai_drivers") is None or importlib.util.find_spec("hai_drivers.desktop") is None:
return CheckResult(
"desktop", True, "local desktop control not installed; add it with: pip install 'hai-agents[desktop]'"
)
if sys.platform != "darwin":
return CheckResult("desktop", True, "deps installed")
missing = _missing_macos_grants()
if missing:
from hai_agents_local.desktop import ACCESSIBILITY_SETTINGS_URL, SCREEN_RECORDING_SETTINGS_URL

return CheckResult(
"desktop",
False,
f"deps installed, but this terminal is missing macOS grants: {', '.join(missing)}",
fix="grant them in System Settings -> Privacy & Security, then restart this terminal: "
f"{ACCESSIBILITY_SETTINGS_URL} and {SCREEN_RECORDING_SETTINGS_URL}",
)
return CheckResult("desktop", True, "deps installed; Accessibility and Screen Recording granted")


def _missing_macos_grants() -> list[str]:
# Non-prompting variants keep the doctor read-only; the bridge preflight does the prompting.
from ApplicationServices import AXIsProcessTrusted
from Quartz import CGPreflightScreenCaptureAccess

missing = []
if not AXIsProcessTrusted():
missing.append("Accessibility")
if not CGPreflightScreenCaptureAccess():
missing.append("Screen Recording")
return missing


def _port_in_use(port: int) -> bool:
with socket.socket() as sock:
sock.settimeout(0.2)
return sock.connect_ex(("127.0.0.1", port)) == 0


def run_checks(api_key: str | None, base_url: str | None) -> list[CheckResult]:
login = check_login(api_key)
checks = [login]
if login.ok:
checks.append(check_platform(api_key, base_url))
checks.extend([check_browser(), check_desktop()])
return checks
Loading