589c5b06fe
- Auto-fixed 70 lint errors (import sorting, aliased errors, datetime.UTC)
- Fixed 85 remaining errors manually:
- E501: wrapped long lines in queen_profiles, catalog, routes_credentials
- F821: added missing TYPE_CHECKING imports for AgentHost, ToolRegistry,
HookContext, HookResult; added runtime imports where needed
- F811: removed duplicate method definitions in queen_lifecycle_tools
- F841/B007: removed unused variables in discovery.py
- W291: removed trailing whitespace in queen nodes
- E402: moved import to top of queen_memory_v2.py
- Fixed AgentRuntime -> AgentHost in example template type annotations
- Reformatted 343 files with ruff format
803 lines
28 KiB
Python
803 lines
28 KiB
Python
"""CLI commands for Hive — queens, colonies, sessions.
|
|
|
|
The new architecture has no exported agents, no graph execution.
|
|
Everything runs through the AgentLoop driven by SessionManager.
|
|
|
|
Commands:
|
|
serve Start the HTTP API server (the runtime hub)
|
|
open Start the server and open the dashboard
|
|
queen Manage queen profiles (list, show, sessions)
|
|
colony Manage colonies (list, info, delete)
|
|
session Manage live + cold sessions (list, stop)
|
|
chat Send a message to a live queen via the HTTP API
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib import error as urlerror, parse as urlparse, request as urlrequest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def register_commands(subparsers: argparse._SubParsersAction) -> None:
|
|
"""Register all runner commands with the main CLI parser."""
|
|
_register_serve(subparsers)
|
|
_register_open(subparsers)
|
|
_register_queen(subparsers)
|
|
_register_colony(subparsers)
|
|
_register_session(subparsers)
|
|
_register_chat(subparsers)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# serve / open
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _register_serve(subparsers: argparse._SubParsersAction) -> None:
|
|
p = subparsers.add_parser(
|
|
"serve",
|
|
help="Start the HTTP API server",
|
|
description="Start the aiohttp server exposing REST + SSE for queens, colonies, and sessions.",
|
|
)
|
|
p.add_argument("--host", type=str, default="127.0.0.1", help="Host to bind (default: 127.0.0.1)")
|
|
p.add_argument("--port", "-p", type=int, default=8787, help="Port to listen on (default: 8787)")
|
|
p.add_argument(
|
|
"--colony",
|
|
"-c",
|
|
type=str,
|
|
action="append",
|
|
default=[],
|
|
help="Colony path or name to preload (repeatable)",
|
|
)
|
|
p.add_argument("--model", "-m", type=str, default=None, help="LLM model for preloaded colonies")
|
|
p.add_argument("--open", action="store_true", help="Open dashboard in browser after start")
|
|
p.add_argument("--verbose", "-v", action="store_true", help="Enable INFO log level")
|
|
p.add_argument("--debug", action="store_true", help="Enable DEBUG log level")
|
|
p.set_defaults(func=cmd_serve)
|
|
|
|
|
|
def _register_open(subparsers: argparse._SubParsersAction) -> None:
|
|
p = subparsers.add_parser(
|
|
"open",
|
|
help="Start the server and open the dashboard",
|
|
description="Shortcut for 'hive serve --open'.",
|
|
)
|
|
p.add_argument("--host", type=str, default="127.0.0.1")
|
|
p.add_argument("--port", "-p", type=int, default=8787)
|
|
p.add_argument("--colony", "-c", type=str, action="append", default=[])
|
|
p.add_argument("--model", "-m", type=str, default=None)
|
|
p.add_argument("--verbose", "-v", action="store_true")
|
|
p.add_argument("--debug", action="store_true")
|
|
p.set_defaults(func=cmd_open)
|
|
|
|
|
|
def cmd_serve(args: argparse.Namespace) -> int:
|
|
"""Start the HTTP API server (the runtime hub)."""
|
|
import atexit
|
|
import logging
|
|
import signal
|
|
|
|
from aiohttp import web
|
|
|
|
_build_frontend()
|
|
|
|
from framework.observability import configure_logging
|
|
from framework.server.app import create_app
|
|
|
|
if getattr(args, "debug", False):
|
|
configure_logging(level="DEBUG")
|
|
else:
|
|
configure_logging(level="INFO")
|
|
|
|
# Last-resort MCP cleanup. Runs on any process exit path, including
|
|
# crashes — so hung MCP subprocesses don't outlive the server. The
|
|
# graceful shutdown path below also disconnects clients; atexit is
|
|
# belt-and-braces and no-ops if already cleaned.
|
|
def _atexit_cleanup_mcp() -> None:
|
|
try:
|
|
from framework.loader.mcp_connection_manager import MCPConnectionManager
|
|
|
|
MCPConnectionManager.get_instance().cleanup_all()
|
|
except Exception as exc: # noqa: BLE001
|
|
logging.getLogger(__name__).debug("atexit MCP cleanup failed: %s", exc)
|
|
|
|
atexit.register(_atexit_cleanup_mcp)
|
|
|
|
model = getattr(args, "model", None)
|
|
app = create_app(model=model)
|
|
|
|
async def run_server() -> None:
|
|
manager = app["manager"]
|
|
shutdown_event = asyncio.Event()
|
|
signal_count = {"n": 0}
|
|
|
|
def _request_shutdown(signame: str) -> None:
|
|
signal_count["n"] += 1
|
|
if signal_count["n"] == 1:
|
|
print(f"\nReceived {signame}, shutting down gracefully… (press Ctrl+C again to force quit)")
|
|
shutdown_event.set()
|
|
else:
|
|
# Second Ctrl+C (or SIGTERM) — the user is done waiting.
|
|
# Skip the graceful teardown and exit immediately. os._exit
|
|
# bypasses atexit handlers, so fire the MCP cleanup manually
|
|
# first to avoid leaking subprocesses.
|
|
print(f"\nReceived {signame} again — force quitting.")
|
|
try:
|
|
from framework.loader.mcp_connection_manager import (
|
|
MCPConnectionManager,
|
|
)
|
|
|
|
MCPConnectionManager.get_instance().cleanup_all()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
os._exit(130)
|
|
|
|
# Register SIGTERM (and explicit SIGINT) so container orchestrators
|
|
# and plain Ctrl-C both route through the same graceful path —
|
|
# manager.shutdown_all() flushes state and disconnects MCP clients.
|
|
loop = asyncio.get_running_loop()
|
|
for signame in ("SIGINT", "SIGTERM"):
|
|
try:
|
|
loop.add_signal_handler(
|
|
getattr(signal, signame),
|
|
_request_shutdown,
|
|
signame,
|
|
)
|
|
except (NotImplementedError, AttributeError):
|
|
# Windows / restricted environments — fall back to default
|
|
# handlers (KeyboardInterrupt for SIGINT; SIGTERM kills).
|
|
pass
|
|
|
|
# Preload colonies specified via --colony
|
|
for colony_arg in getattr(args, "colony", []) or []:
|
|
colony_path = _resolve_colony_path(colony_arg)
|
|
if colony_path is None:
|
|
print(f"Colony not found: {colony_arg}")
|
|
continue
|
|
try:
|
|
session = await manager.create_session_with_worker_colony(str(colony_path), model=model)
|
|
info = session.worker_info
|
|
name = info.name if info else session.colony_id
|
|
print(f"Loaded colony: {session.colony_id} ({name}) → session {session.id}")
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"Error loading colony {colony_arg}: {e}")
|
|
|
|
runner = web.AppRunner(app, access_log=None)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, args.host, args.port)
|
|
await site.start()
|
|
|
|
dashboard_url = f"http://{args.host}:{args.port}"
|
|
has_frontend = _frontend_dist_exists()
|
|
|
|
live_count = sum(1 for s in manager.list_sessions() if s.colony_runtime is not None)
|
|
queen_only = sum(1 for s in manager.list_sessions() if s.colony_runtime is None)
|
|
|
|
print()
|
|
print(f"Hive API server running on {dashboard_url}")
|
|
if has_frontend:
|
|
print(f"Dashboard: {dashboard_url}")
|
|
print(f"Health: {dashboard_url}/api/health")
|
|
print(f"Sessions: {live_count} colony, {queen_only} queen-only")
|
|
print()
|
|
print("Press Ctrl+C to stop")
|
|
|
|
if getattr(args, "open", False) and has_frontend:
|
|
_open_browser(dashboard_url)
|
|
|
|
try:
|
|
await shutdown_event.wait()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
await manager.shutdown_all()
|
|
await runner.cleanup()
|
|
|
|
try:
|
|
asyncio.run(run_server())
|
|
except KeyboardInterrupt:
|
|
print("\nServer stopped.")
|
|
return 0
|
|
|
|
|
|
def cmd_open(args: argparse.Namespace) -> int:
|
|
"""Start the HTTP server and open the dashboard in the browser."""
|
|
_ping_hive_gateway_availability("hive-open")
|
|
args.open = True
|
|
return cmd_serve(args)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# queen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _register_queen(subparsers: argparse._SubParsersAction) -> None:
|
|
p = subparsers.add_parser(
|
|
"queen",
|
|
help="Manage queen profiles",
|
|
description="List, inspect, and explore queen identities.",
|
|
)
|
|
sub = p.add_subparsers(dest="subcommand", required=True)
|
|
|
|
list_p = sub.add_parser("list", help="List all queen profiles")
|
|
list_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
list_p.set_defaults(func=cmd_queen_list)
|
|
|
|
show_p = sub.add_parser("show", help="Show a queen profile")
|
|
show_p.add_argument("queen_id", type=str, help="Queen identity (e.g. queen_technology)")
|
|
show_p.add_argument("--json", action="store_true", help="Output as JSON")
|
|
show_p.set_defaults(func=cmd_queen_show)
|
|
|
|
sess_p = sub.add_parser("sessions", help="List sessions belonging to a queen")
|
|
sess_p.add_argument("queen_id", type=str, help="Queen identity")
|
|
sess_p.add_argument("--json", action="store_true")
|
|
sess_p.set_defaults(func=cmd_queen_sessions)
|
|
|
|
|
|
def cmd_queen_list(args: argparse.Namespace) -> int:
|
|
from framework.agents.queen.queen_profiles import ensure_default_queens, list_queens
|
|
|
|
ensure_default_queens()
|
|
queens = list_queens()
|
|
if args.json:
|
|
print(json.dumps(queens, indent=2))
|
|
return 0
|
|
|
|
if not queens:
|
|
print("No queen profiles found.")
|
|
return 0
|
|
|
|
print(f"{'ID':<32} {'NAME':<24} TITLE")
|
|
print("-" * 80)
|
|
for q in queens:
|
|
print(f"{q['id']:<32} {q['name']:<24} {q['title']}")
|
|
return 0
|
|
|
|
|
|
def cmd_queen_show(args: argparse.Namespace) -> int:
|
|
from framework.agents.queen.queen_profiles import load_queen_profile
|
|
|
|
try:
|
|
profile = load_queen_profile(args.queen_id)
|
|
except FileNotFoundError as e:
|
|
print(f"Error: {e}")
|
|
return 1
|
|
|
|
if args.json:
|
|
print(json.dumps(profile, indent=2))
|
|
return 0
|
|
|
|
print(f"Queen ID: {args.queen_id}")
|
|
print(f"Name: {profile.get('name', '')}")
|
|
print(f"Title: {profile.get('title', '')}")
|
|
desc = profile.get("description") or profile.get("core_traits") or ""
|
|
if isinstance(desc, list):
|
|
desc = ", ".join(desc)
|
|
if desc:
|
|
print(f"Traits: {desc}")
|
|
skills = profile.get("skills") or []
|
|
if skills:
|
|
print(f"Skills: {', '.join(skills) if isinstance(skills, list) else skills}")
|
|
return 0
|
|
|
|
|
|
def cmd_queen_sessions(args: argparse.Namespace) -> int:
|
|
from framework.config import QUEENS_DIR
|
|
|
|
queen_dir = QUEENS_DIR / args.queen_id / "sessions"
|
|
if not queen_dir.is_dir():
|
|
print(f"No sessions for queen '{args.queen_id}'")
|
|
return 0
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
for session_dir in sorted(queen_dir.iterdir()):
|
|
if not session_dir.is_dir():
|
|
continue
|
|
meta_path = session_dir / "meta.json"
|
|
meta: dict = {}
|
|
if meta_path.exists():
|
|
try:
|
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
meta = {}
|
|
rows.append(
|
|
{
|
|
"session_id": session_dir.name,
|
|
"phase": meta.get("phase", "?"),
|
|
"agent_path": meta.get("agent_path", ""),
|
|
"colony_fork": bool(meta.get("colony_fork")),
|
|
}
|
|
)
|
|
|
|
if args.json:
|
|
print(json.dumps(rows, indent=2))
|
|
return 0
|
|
|
|
if not rows:
|
|
print(f"No sessions for queen '{args.queen_id}'")
|
|
return 0
|
|
|
|
print(f"{'SESSION':<40} {'PHASE':<10} {'COLONY':<20} FLAGS")
|
|
print("-" * 90)
|
|
for r in rows:
|
|
flags = "fork" if r["colony_fork"] else ""
|
|
colony = Path(r["agent_path"]).name if r["agent_path"] else ""
|
|
print(f"{r['session_id']:<40} {r['phase']:<10} {colony:<20} {flags}")
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# colony
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _register_colony(subparsers: argparse._SubParsersAction) -> None:
|
|
p = subparsers.add_parser(
|
|
"colony",
|
|
help="Manage colonies",
|
|
description="List, inspect, and delete colonies on disk.",
|
|
)
|
|
sub = p.add_subparsers(dest="subcommand", required=True)
|
|
|
|
list_p = sub.add_parser("list", help="List all colonies")
|
|
list_p.add_argument("--json", action="store_true")
|
|
list_p.set_defaults(func=cmd_colony_list)
|
|
|
|
info_p = sub.add_parser("info", help="Show colony details")
|
|
info_p.add_argument("name", type=str, help="Colony name or path")
|
|
info_p.add_argument("--json", action="store_true")
|
|
info_p.set_defaults(func=cmd_colony_info)
|
|
|
|
del_p = sub.add_parser("delete", help="Delete a colony from disk")
|
|
del_p.add_argument("name", type=str, help="Colony name")
|
|
del_p.add_argument(
|
|
"--purge-storage",
|
|
action="store_true",
|
|
help="Also delete worker storage at ~/.hive/agents/{name}/",
|
|
)
|
|
del_p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
|
|
del_p.set_defaults(func=cmd_colony_delete)
|
|
|
|
|
|
def cmd_colony_list(args: argparse.Namespace) -> int:
|
|
from framework.config import COLONIES_DIR
|
|
|
|
if not COLONIES_DIR.is_dir():
|
|
if args.json:
|
|
print("[]")
|
|
else:
|
|
print("No colonies found.")
|
|
return 0
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
for path in sorted(COLONIES_DIR.iterdir()):
|
|
if not path.is_dir():
|
|
continue
|
|
meta_path = path / "metadata.json"
|
|
meta: dict = {}
|
|
if meta_path.exists():
|
|
try:
|
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
meta = {}
|
|
worker_count = sum(
|
|
1 for f in path.iterdir() if f.is_file() and f.suffix == ".json" and f.stem not in _RESERVED_JSON_STEMS
|
|
)
|
|
rows.append(
|
|
{
|
|
"name": path.name,
|
|
"queen_name": meta.get("queen_name", ""),
|
|
"queen_session_id": meta.get("queen_session_id", ""),
|
|
"workers": worker_count,
|
|
"created_at": meta.get("created_at", ""),
|
|
"path": str(path),
|
|
}
|
|
)
|
|
|
|
if args.json:
|
|
print(json.dumps(rows, indent=2))
|
|
return 0
|
|
|
|
if not rows:
|
|
print("No colonies found.")
|
|
return 0
|
|
|
|
print(f"{'NAME':<24} {'QUEEN':<28} {'WORKERS':<8} CREATED")
|
|
print("-" * 90)
|
|
for r in rows:
|
|
print(f"{r['name']:<24} {r['queen_name']:<28} {r['workers']:<8} {r['created_at'][:19]}")
|
|
return 0
|
|
|
|
|
|
def cmd_colony_info(args: argparse.Namespace) -> int:
|
|
colony_path = _resolve_colony_path(args.name)
|
|
if colony_path is None:
|
|
print(f"Colony not found: {args.name}")
|
|
return 1
|
|
|
|
meta_path = colony_path / "metadata.json"
|
|
metadata: dict = {}
|
|
if meta_path.exists():
|
|
try:
|
|
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
pass
|
|
|
|
workers: dict[str, dict] = {}
|
|
for f in sorted(colony_path.iterdir()):
|
|
if not (f.is_file() and f.suffix == ".json"):
|
|
continue
|
|
if f.stem in _RESERVED_JSON_STEMS:
|
|
continue
|
|
try:
|
|
data = json.loads(f.read_text(encoding="utf-8"))
|
|
if isinstance(data, dict):
|
|
workers[f.stem] = {
|
|
"name": data.get("name", f.stem),
|
|
"description": data.get("description", ""),
|
|
"tools": len(data.get("tools", [])),
|
|
"goal": data.get("goal", {}).get("description", ""),
|
|
"spawned_from": data.get("spawned_from", ""),
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
if args.json:
|
|
print(json.dumps({"path": str(colony_path), "metadata": metadata, "workers": workers}, indent=2))
|
|
return 0
|
|
|
|
print(f"Colony: {colony_path.name}")
|
|
print(f"Path: {colony_path}")
|
|
print(f"Queen: {metadata.get('queen_name', '?')}")
|
|
print(f"Queen Session: {metadata.get('queen_session_id', '?')}")
|
|
print(f"Source Session: {metadata.get('source_session_id', '?')}")
|
|
print(f"Created: {metadata.get('created_at', '?')}")
|
|
print()
|
|
print(f"Workers ({len(workers)}):")
|
|
for wname, w in workers.items():
|
|
print(f" • {wname}")
|
|
if w["goal"]:
|
|
print(f" goal: {w['goal'][:80]}")
|
|
print(f" tools: {w['tools']}")
|
|
if w["spawned_from"]:
|
|
print(f" from: {w['spawned_from']}")
|
|
return 0
|
|
|
|
|
|
def cmd_colony_delete(args: argparse.Namespace) -> int:
|
|
from framework.config import COLONIES_DIR, HIVE_HOME
|
|
|
|
colony_path = COLONIES_DIR / args.name
|
|
if not colony_path.is_dir():
|
|
print(f"Colony not found: {args.name}")
|
|
return 1
|
|
|
|
storage_path = HIVE_HOME / "agents" / args.name
|
|
purge_storage = args.purge_storage and storage_path.is_dir()
|
|
|
|
if not args.yes:
|
|
print(f"This will permanently delete: {colony_path}")
|
|
if purge_storage:
|
|
print(f"And worker storage at: {storage_path}")
|
|
confirm = input("Type the colony name to confirm: ").strip()
|
|
if confirm != args.name:
|
|
print("Cancelled.")
|
|
return 1
|
|
|
|
shutil.rmtree(colony_path)
|
|
print(f"Deleted {colony_path}")
|
|
if purge_storage:
|
|
shutil.rmtree(storage_path)
|
|
print(f"Deleted {storage_path}")
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# session
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _register_session(subparsers: argparse._SubParsersAction) -> None:
|
|
p = subparsers.add_parser(
|
|
"session",
|
|
help="Manage sessions",
|
|
description="List live and cold sessions, stop running sessions.",
|
|
)
|
|
sub = p.add_subparsers(dest="subcommand", required=True)
|
|
|
|
list_p = sub.add_parser("list", help="List sessions")
|
|
list_p.add_argument("--cold", action="store_true", help="Include cold (on-disk) sessions")
|
|
list_p.add_argument("--server", default="http://127.0.0.1:8787", help="Hive server URL")
|
|
list_p.add_argument("--json", action="store_true")
|
|
list_p.set_defaults(func=cmd_session_list)
|
|
|
|
stop_p = sub.add_parser("stop", help="Stop a live session")
|
|
stop_p.add_argument("session_id", type=str, help="Session ID to stop")
|
|
stop_p.add_argument("--server", default="http://127.0.0.1:8787")
|
|
stop_p.set_defaults(func=cmd_session_stop)
|
|
|
|
|
|
def cmd_session_list(args: argparse.Namespace) -> int:
|
|
if args.cold:
|
|
# Read directly from disk -- works without server
|
|
from framework.server.session_manager import SessionManager
|
|
|
|
rows = SessionManager.list_cold_sessions()
|
|
else:
|
|
# Hit the server's live session endpoint
|
|
try:
|
|
data = _http_get(f"{args.server}/api/sessions")
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"Could not reach server at {args.server}: {e}")
|
|
print("Tip: pass --cold to read on-disk sessions, or start 'hive serve' first.")
|
|
return 1
|
|
rows = data.get("sessions", [])
|
|
|
|
if args.json:
|
|
print(json.dumps(rows, indent=2))
|
|
return 0
|
|
|
|
if not rows:
|
|
print("No sessions.")
|
|
return 0
|
|
|
|
print(f"{'SESSION':<40} {'COLONY':<20} {'PHASE':<12} WORKER")
|
|
print("-" * 90)
|
|
for r in rows:
|
|
sid = r.get("session_id", "?")
|
|
colony = r.get("colony_name") or r.get("colony_id") or ""
|
|
phase = r.get("queen_phase", "?")
|
|
has_worker = "yes" if r.get("has_worker") else "no"
|
|
print(f"{sid:<40} {colony:<20} {phase:<12} {has_worker}")
|
|
return 0
|
|
|
|
|
|
def cmd_session_stop(args: argparse.Namespace) -> int:
|
|
try:
|
|
data = _http_delete(f"{args.server}/api/sessions/{args.session_id}")
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"Could not reach server at {args.server}: {e}")
|
|
return 1
|
|
if data.get("stopped"):
|
|
print(f"Stopped session {args.session_id}")
|
|
return 0
|
|
print(f"Failed to stop session: {data}")
|
|
return 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# chat
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _register_chat(subparsers: argparse._SubParsersAction) -> None:
|
|
p = subparsers.add_parser(
|
|
"chat",
|
|
help="Send a message to a live queen session",
|
|
description="POST a chat message to a running session via the HTTP API.",
|
|
)
|
|
p.add_argument("session_id", type=str, help="Session ID")
|
|
p.add_argument("message", type=str, help="Message text")
|
|
p.add_argument("--server", default="http://127.0.0.1:8787", help="Hive server URL")
|
|
p.set_defaults(func=cmd_chat)
|
|
|
|
|
|
def cmd_chat(args: argparse.Namespace) -> int:
|
|
try:
|
|
data = _http_post(
|
|
f"{args.server}/api/sessions/{args.session_id}/chat",
|
|
{"message": args.message},
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"Could not reach server at {args.server}: {e}")
|
|
return 1
|
|
if "error" in data:
|
|
print(f"Error: {data['error']}")
|
|
return 1
|
|
print(f"Sent. Tail the SSE stream at {args.server}/api/sessions/{args.session_id}/events")
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# JSON files inside ~/.hive/colonies/{name}/ that are NOT worker configs.
|
|
_RESERVED_JSON_STEMS = {"agent", "flowchart", "triggers", "configuration", "metadata"}
|
|
|
|
|
|
def _resolve_colony_path(name_or_path: str) -> Path | None:
|
|
"""Resolve a colony argument to its on-disk Path.
|
|
|
|
Accepts either an absolute/relative path to a colony directory or
|
|
a bare colony name (looked up under ~/.hive/colonies/{name}/).
|
|
"""
|
|
from framework.config import COLONIES_DIR
|
|
|
|
candidate = Path(name_or_path).expanduser()
|
|
if candidate.is_dir():
|
|
return candidate
|
|
by_name = COLONIES_DIR / name_or_path
|
|
if by_name.is_dir():
|
|
return by_name
|
|
return None
|
|
|
|
|
|
def _http_get(url: str, timeout: float = 10.0) -> dict:
|
|
req = urlrequest.Request(url, method="GET")
|
|
with urlrequest.urlopen(req, timeout=timeout) as r:
|
|
return json.loads(r.read().decode("utf-8"))
|
|
|
|
|
|
def _http_post(url: str, body: dict, timeout: float = 30.0) -> dict:
|
|
data = json.dumps(body).encode("utf-8")
|
|
req = urlrequest.Request(url, data=data, method="POST", headers={"Content-Type": "application/json"})
|
|
with urlrequest.urlopen(req, timeout=timeout) as r:
|
|
return json.loads(r.read().decode("utf-8"))
|
|
|
|
|
|
def _http_delete(url: str, timeout: float = 10.0) -> dict:
|
|
req = urlrequest.Request(url, method="DELETE")
|
|
with urlrequest.urlopen(req, timeout=timeout) as r:
|
|
return json.loads(r.read().decode("utf-8"))
|
|
|
|
|
|
def _frontend_dist_exists() -> bool:
|
|
candidates = [Path("frontend/dist"), Path("core/frontend/dist")]
|
|
return any((c / "index.html").exists() for c in candidates if c.is_dir())
|
|
|
|
|
|
def _find_chrome_bin() -> str | None:
|
|
"""Return the path to a Chrome/Chromium binary, or None if not found."""
|
|
for candidate in (
|
|
"google-chrome",
|
|
"google-chrome-stable",
|
|
"chromium",
|
|
"chromium-browser",
|
|
"microsoft-edge",
|
|
"microsoft-edge-stable",
|
|
):
|
|
if shutil.which(candidate):
|
|
return candidate
|
|
|
|
mac_paths = [
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
Path.home() / "Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
]
|
|
for p in mac_paths:
|
|
if Path(p).exists():
|
|
return str(p)
|
|
return None
|
|
|
|
|
|
def _open_browser(url: str) -> None:
|
|
"""Open URL in the browser (best-effort, non-blocking)."""
|
|
chrome = _find_chrome_bin()
|
|
try:
|
|
if chrome:
|
|
subprocess.Popen(
|
|
[chrome, url],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
if sys.platform == "darwin":
|
|
subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
elif sys.platform == "win32":
|
|
subprocess.Popen(
|
|
["cmd", "/c", "start", "", url],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
elif sys.platform == "linux":
|
|
subprocess.Popen(["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _ping_hive_gateway_availability(from_source: str) -> None:
|
|
"""Best-effort reachability ping to the Hive gateway."""
|
|
base_url = "https://api.adenhq.com/v1/gateway/availability"
|
|
query = urlparse.urlencode({"from": from_source})
|
|
url = f"{base_url}?{query}"
|
|
try:
|
|
with urlrequest.urlopen(url, timeout=5) as response:
|
|
response.read()
|
|
except (urlerror.URLError, TimeoutError, ValueError):
|
|
pass
|
|
|
|
|
|
def _format_subprocess_output(output: str | bytes | None, limit: int = 2000) -> str:
|
|
if not output:
|
|
return ""
|
|
text = output.decode(errors="replace") if isinstance(output, bytes) else output
|
|
text = text.strip()
|
|
return text if len(text) <= limit else text[-limit:]
|
|
|
|
|
|
def _build_frontend() -> bool:
|
|
"""Build the frontend if source is newer than dist. Returns True if dist exists."""
|
|
candidates = [
|
|
Path("core/frontend"),
|
|
Path(__file__).resolve().parent.parent.parent / "frontend",
|
|
]
|
|
frontend_dir: Path | None = None
|
|
for c in candidates:
|
|
if (c / "package.json").is_file():
|
|
frontend_dir = c.resolve()
|
|
break
|
|
|
|
if frontend_dir is None:
|
|
return False
|
|
|
|
dist_dir = frontend_dir / "dist"
|
|
src_dir = frontend_dir / "src"
|
|
|
|
index_html = dist_dir / "index.html"
|
|
if index_html.exists() and src_dir.is_dir():
|
|
dist_mtime = index_html.stat().st_mtime
|
|
needs_build = False
|
|
for f in src_dir.rglob("*"):
|
|
if f.is_file() and f.stat().st_mtime > dist_mtime:
|
|
needs_build = True
|
|
break
|
|
if not needs_build:
|
|
return True
|
|
|
|
print("Building frontend...")
|
|
npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm"
|
|
try:
|
|
for cache_file in frontend_dir.glob("tsconfig*.tsbuildinfo"):
|
|
cache_file.unlink(missing_ok=True)
|
|
|
|
subprocess.run(
|
|
[npm_cmd, "install", "--no-fund", "--no-audit"],
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
cwd=frontend_dir,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
[npm_cmd, "run", "build"],
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
cwd=frontend_dir,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
print("Frontend built.")
|
|
return True
|
|
except FileNotFoundError:
|
|
print("Node.js not found — skipping frontend build.")
|
|
return dist_dir.is_dir()
|
|
except subprocess.CalledProcessError as exc:
|
|
stdout = _format_subprocess_output(exc.stdout)
|
|
stderr = _format_subprocess_output(exc.stderr)
|
|
cmd = " ".join(exc.cmd) if isinstance(exc.cmd, (list, tuple)) else str(exc.cmd)
|
|
details = "\n".join(part for part in [stdout, stderr] if part).strip()
|
|
if details:
|
|
print(f"Frontend build failed while running {cmd}:\n{details}")
|
|
else:
|
|
print(f"Frontend build failed while running {cmd} (exit {exc.returncode}).")
|
|
return dist_dir.is_dir()
|