Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,17 @@ venv/
uv.lock
.claude/*.local.md
node_modules/

# Vite build output (regenerated by `npm run dashboard:build`)
clawteam/board/static/assets/

# Electron packaging output
release/
dist-electron/

# AnyFS workspace snapshots — local-only, may contain credentials
workspace.json
process.json
skills.json

.DS_Store
50 changes: 50 additions & 0 deletions clawteam/board/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,55 @@ def collect_team(self, team_name: str) -> dict:
except Exception:
pass

# Spawn/session registry data. This is intentionally best-effort:
# dashboards should still render if tmux/wsh/process probing fails.
sessions = []
registry = {}
try:
from clawteam.spawn.registry import get_registry, is_agent_alive
from clawteam.spawn.sessions import SessionStore

registry = get_registry(team_name)
session_store = SessionStore(team_name)
for agent_name, info in sorted(registry.items()):
backend = info.get("backend", "")
target = info.get("tmux_target") or info.get("block_id") or ""
saved_session = session_store.load(agent_name)
session_state = saved_session.state if saved_session else {}
try:
alive = is_agent_alive(team_name, agent_name)
except Exception:
alive = None
sessions.append(
{
"agentName": agent_name,
"backend": backend,
"target": target,
"tmuxTarget": info.get("tmux_target", ""),
"blockId": info.get("block_id", ""),
"pid": info.get("pid", 0),
"command": info.get("command", []),
"spawnedAt": info.get("spawned_at", 0),
"alive": alive,
"sessionId": saved_session.session_id if saved_session else "",
"sessionSavedAt": saved_session.saved_at if saved_session else "",
"sessionClient": session_state.get("client", ""),
"sessionSource": session_state.get("source", ""),
"sessionConfidence": session_state.get("confidence", ""),
"sessionCwd": session_state.get("cwd", ""),
}
)
except Exception:
registry = {}

for member in members:
session_info = registry.get(member["name"])
if session_info:
member["session"] = {
"backend": session_info.get("backend", ""),
"target": session_info.get("tmux_target") or session_info.get("block_id") or "",
}

return {
"team": {
"name": config.name,
Expand All @@ -186,6 +235,7 @@ def collect_team(self, team_name: str) -> dict:
"tasks": grouped,
"taskSummary": summary,
"messages": all_messages,
"sessions": sessions,
"cost": cost_data,
"conflicts": conflict_data,
}
Expand Down
88 changes: 88 additions & 0 deletions clawteam/board/runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Runtime installation and version helpers for the dashboard."""

from __future__ import annotations

import json
import platform
import re
import shutil
import urllib.error
import urllib.request
from importlib import metadata
from pathlib import Path

from clawteam import __version__

PYPI_URL = "https://pypi.org/pypi/clawteam/json"


def get_runtime_status(timeout: float = 4.0) -> dict:
"""Return local ClawTeam runtime status for the Web/Electron dashboard."""

current_version = _installed_version()
latest_version = _latest_pypi_version(timeout=timeout)
display_latest_version = (
current_version if _is_newer(current_version, latest_version) else latest_version
)
command_path = _resolve_command_path()
return {
"installed": bool(current_version or command_path),
"current_version": current_version,
"latest_version": display_latest_version,
"upgrade_available": _is_newer(latest_version, current_version),
"command_path": command_path,
"install_root": str(Path.home() / ".clawteam"),
"platform": platform.system().lower(),
"source": "pypi",
}


def _installed_version() -> str:
try:
return _normalize_runtime_version(metadata.version("clawteam"))
except metadata.PackageNotFoundError:
return __version__


def _latest_pypi_version(timeout: float) -> str | None:
try:
req = urllib.request.Request(PYPI_URL, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
version = payload.get("info", {}).get("version")
return _normalize_runtime_version(str(version)) if version else None
except (OSError, urllib.error.URLError, json.JSONDecodeError, TimeoutError):
return None


def _normalize_runtime_version(value: str | None) -> str | None:
if not value:
return None
text = value.strip()
if __version__.endswith(text):
return __version__
return text


def _resolve_command_path() -> str:
candidates = [
Path.home() / ".clawteam" / ".venv" / "bin" / "clawteam",
Path.home() / ".local" / "bin" / "clawteam",
]
for candidate in candidates:
if candidate.exists():
return str(candidate)
return shutil.which("clawteam") or ""


def _version_key(value: str | None) -> tuple[int, ...]:
if not value:
return ()
parts = re.findall(r"\d+", value)
return tuple(int(part) for part in parts[:4])


def _is_newer(latest: str | None, current: str | None) -> bool:
latest_key = _version_key(latest)
current_key = _version_key(current)
return bool(latest_key and current_key and latest_key > current_key)
32 changes: 28 additions & 4 deletions clawteam/board/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import ipaddress
import json
import mimetypes
import threading
import time
import urllib.error
Expand All @@ -14,6 +15,7 @@
from urllib.parse import parse_qs, urlparse

from clawteam.board.collector import BoardCollector
from clawteam.board.runtime import get_runtime_status

_STATIC_DIR = Path(__file__).parent / "static"
_ALLOWED_PROXY_HOSTS = {
Expand Down Expand Up @@ -129,9 +131,11 @@ def do_GET(self):
path = self.path.split("?")[0]

if path == "/" or path == "/index.html":
self._serve_static("index.html", "text/html")
self._serve_static("index.html")
elif path == "/api/overview":
self._serve_json(self.collector.collect_overview())
elif path == "/api/runtime/status":
self._serve_json(get_runtime_status())
elif path.startswith("/api/team/"):
team_name = path[len("/api/team/"):].strip("/")
if not team_name:
Expand Down Expand Up @@ -160,8 +164,10 @@ def do_GET(self):
self.send_error(403, str(e))
except Exception as e:
self.send_error(500, str(e))
elif self._static_exists(path.strip("/")):
self._serve_static(path.strip("/"))
else:
self.send_error(404)
self._serve_static("index.html")

def do_POST(self):
path = self.path.split("?")[0]
Expand All @@ -186,12 +192,30 @@ def do_POST(self):
return
self.send_error(404)

def _serve_static(self, filename: str, content_type: str):
filepath = _STATIC_DIR / filename
def _static_path(self, filename: str) -> Path | None:
try:
filepath = (_STATIC_DIR / filename).resolve()
filepath.relative_to(_STATIC_DIR.resolve())
return filepath
except ValueError:
return None

def _static_exists(self, filename: str) -> bool:
filepath = self._static_path(filename)
return bool(filepath and filepath.is_file())

def _serve_static(self, filename: str):
filepath = self._static_path(filename)
if filepath is None:
self.send_error(403, "Static path is not allowed")
return
if not filepath.exists():
self.send_error(404, f"Static file not found: {filename}")
return
content = filepath.read_bytes()
content_type = mimetypes.guess_type(filepath.name)[0] or "application/octet-stream"
if filepath.suffix == ".js":
content_type = "text/javascript"
self.send_response(200)
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
Comment on lines 215 to 221
Expand Down
Loading