diff --git a/.gitignore b/.gitignore
index d5b2d279..e2403366 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/clawteam/board/collector.py b/clawteam/board/collector.py
index bcdaaa44..dc4c93b1 100644
--- a/clawteam/board/collector.py
+++ b/clawteam/board/collector.py
@@ -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,
@@ -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,
}
diff --git a/clawteam/board/runtime.py b/clawteam/board/runtime.py
new file mode 100644
index 00000000..70ebe5b5
--- /dev/null
+++ b/clawteam/board/runtime.py
@@ -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)
diff --git a/clawteam/board/server.py b/clawteam/board/server.py
index 77054e7f..0ab56da5 100644
--- a/clawteam/board/server.py
+++ b/clawteam/board/server.py
@@ -4,6 +4,7 @@
import ipaddress
import json
+import mimetypes
import threading
import time
import urllib.error
@@ -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 = {
@@ -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:
@@ -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]
@@ -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)))
diff --git a/clawteam/board/static/index.html b/clawteam/board/static/index.html
index 2873a22d..bd85c475 100644
--- a/clawteam/board/static/index.html
+++ b/clawteam/board/static/index.html
@@ -1,851 +1,13 @@
-
+
-
-
-
- ClawTeam Nexus
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Welcome to ClawTeam Nexus
-
Select a highly-intelligent swarm to begin real-time monitoring.
-
-
-
-
-
-
-
-
Inject New Task
-
-
-
-
-
-
-
-
-
-
-
-
-
Set Mission Context
-
Feed a massive project specification or a GitHub README directly to the Lead Agent.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ ClawTeam Dashboard
+
+
+
+
+
+
diff --git a/clawteam/cli/commands.py b/clawteam/cli/commands.py
index dfa5740d..2efcaab0 100644
--- a/clawteam/cli/commands.py
+++ b/clawteam/cli/commands.py
@@ -1162,6 +1162,7 @@ def team_spawn_team(
):
"""Create a new team and register the leader (spawnTeam)."""
from clawteam.identity import AgentIdentity
+ from clawteam.spawn.session_capture import save_current_agent_session
from clawteam.team.manager import TeamManager
identity = AgentIdentity.from_env()
@@ -1182,11 +1183,15 @@ def team_spawn_team(
"leadAgentId": leader_id,
"leaderName": leader_name,
}
+ session_id = save_current_agent_session(name, leader_name)
+ if session_id:
+ result["sessionId"] = session_id
if identity.user:
result["user"] = identity.user
_output(result, lambda d: (
console.print(f"[green]OK[/green] Team '{name}' created"),
console.print(f" Leader: {leader_name} (id: {leader_id})"),
+ console.print(f" Session: {d['sessionId']}") if d.get("sessionId") else None,
))
except ValueError as e:
if _json_output:
@@ -1573,6 +1578,53 @@ def _human(d):
_output(data, _human)
+@team_app.command("watch")
+def team_watch(
+ team: str = typer.Argument(..., help="Team name"),
+ leader: Optional[str] = typer.Option(None, "--leader", "-l", help="Leader agent name (default: from team config)"),
+ interval: float = typer.Option(60.0, "--interval", "-i", help="Polling fallback interval in seconds"),
+ heartbeat_interval: float = typer.Option(
+ 300.0,
+ "--heartbeat-interval",
+ help="Periodic reminder interval in seconds even when state is unchanged",
+ ),
+ redis_mode: str = typer.Option(
+ "auto",
+ "--redis",
+ help="Redis wakeup mode: auto, off, or redis://host:port/db",
+ ),
+):
+ """Watch team state and periodically wake the leader agent."""
+ from clawteam.team.leader_watcher import LeaderWatcher
+ from clawteam.team.manager import TeamManager
+
+ config = TeamManager.get_team(team)
+ if not config:
+ _output({"error": f"Team '{team}' not found"}, lambda d: console.print(f"[red]{d['error']}[/red]"))
+ raise typer.Exit(1)
+
+ leader_name = leader or TeamManager.get_leader_name(team)
+ if not leader_name:
+ _output({"error": f"No leader found for team '{team}'"}, lambda d: console.print(f"[red]{d['error']}[/red]"))
+ raise typer.Exit(1)
+
+ watcher = LeaderWatcher(
+ team_name=team,
+ leader_name=leader_name,
+ interval=interval,
+ heartbeat_interval=heartbeat_interval,
+ redis_mode=redis_mode,
+ json_output=_json_output,
+ verbose=not _json_output,
+ )
+ if not _json_output:
+ console.print(
+ f"Watching team '[cyan]{team}[/cyan]' for leader '[cyan]{leader_name}[/cyan]' "
+ f"(interval: {interval}s, heartbeat: {heartbeat_interval}s, redis: {redis_mode})."
+ )
+ watcher.run()
+
+
@team_app.command("snapshot")
def team_snapshot(
team: str = typer.Argument(..., help="Team name"),
@@ -2572,9 +2624,11 @@ def _print_incomplete_tasks(task_details: list[dict]):
@session_app.command("save")
def session_save(
team: str = typer.Argument(..., help="Team name"),
- session_id: str = typer.Option("", "--session-id", "-s", help="Claude Code session ID"),
+ session_id: str = typer.Option("", "--session-id", "-s", help="Native client session ID"),
last_task: str = typer.Option("", "--last-task", help="Last task ID worked on"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent name (default: from env)"),
+ client: str = typer.Option("", "--client", help="Client name such as claude, codex, gemini"),
+ cwd: str = typer.Option("", "--cwd", help="Workspace directory for this session"),
):
"""Save agent session for later resume."""
from clawteam.identity import AgentIdentity
@@ -2586,6 +2640,12 @@ def session_save(
agent_name=agent_name,
session_id=session_id,
last_task_id=last_task,
+ state={
+ "client": client,
+ "source": "manual",
+ "cwd": cwd,
+ "confidence": "exact" if session_id else "",
+ },
)
data = _dump(session)
_output(data, lambda d: console.print(f"[green]OK[/green] Session saved for '{agent_name}'"))
@@ -2606,9 +2666,13 @@ def session_show(
_output({"error": f"No session for '{agent}'"}, lambda d: console.print(f"[dim]{d['error']}[/dim]"))
return
data = _dump(session)
+ state = data.get("state") or {}
_output(data, lambda d: (
console.print(f"Session: [cyan]{d.get('agentName', '')}[/cyan]"),
console.print(f" Session ID: {d.get('sessionId', '')}"),
+ console.print(f" Client: {state.get('client', '')}"),
+ console.print(f" Source: {state.get('source', '')} ({state.get('confidence', '')})"),
+ console.print(f" CWD: {state.get('cwd', '')}"),
console.print(f" Last task: {d.get('lastTaskId', '')}"),
console.print(f" Saved at: {format_timestamp(d.get('savedAt', ''))}"),
))
@@ -2622,12 +2686,17 @@ def _human(items):
return
table = Table(title=f"Sessions — {team}")
table.add_column("Agent", style="cyan")
+ table.add_column("Client")
+ table.add_column("Confidence")
table.add_column("Session ID")
table.add_column("Last Task", style="dim")
table.add_column("Saved At", style="dim")
for s in items:
+ state = s.get("state") or {}
table.add_row(
s.get("agentName", ""),
+ state.get("client", ""),
+ state.get("confidence", ""),
s.get("sessionId", ""),
s.get("lastTaskId", ""),
format_timestamp(s.get("savedAt")),
@@ -3197,15 +3266,17 @@ def spawn_agent(
repo_path=repo,
)
- # Session resume: inject --resume flag for claude commands
+ # Session resume: inject the native client resume flag.
if resume:
from clawteam.spawn.sessions import SessionStore
+ from clawteam.spawn.session_capture import build_resume_command as build_cli_resume_command
session_store = SessionStore(_team)
session = session_store.load(_name)
if session and session.session_id:
- # Add --resume to claude command
- if command and Path(command[0]).name in ("claude", "claude-code"):
- command = list(command) + ["--resume", session.session_id]
+ client = str((session.state or {}).get("client") or "")
+ resumed_command = build_cli_resume_command(command, session.session_id, client=client)
+ if resumed_command != list(command):
+ command = resumed_command
console.print(f"[dim]Resuming session: {session.session_id}[/dim]")
if prompt:
prompt += "\nYou are resuming a previous session."
@@ -4495,7 +4566,7 @@ def run_command(
from clawteam.harness.prompts import build_harness_system_prompt, build_wrapped_prompt
from clawteam.spawn import get_backend
- from clawteam.spawn.adapters import is_claude_command
+ from clawteam.spawn.session_capture import build_resume_command as build_cli_resume_command
from clawteam.team.manager import TeamManager
mgr = TeamManager
@@ -4566,8 +4637,11 @@ def run_command(
from clawteam.spawn.sessions import SessionStore
session = SessionStore(team).load(agent_name)
- if session and session.session_id and is_claude_command(command_list):
- command_list = [*command_list, "--resume", session.session_id]
+ if session and session.session_id:
+ client = str((session.state or {}).get("client") or "")
+ resumed_command = build_cli_resume_command(command_list, session.session_id, client=client)
+ if resumed_command != command_list:
+ command_list = resumed_command
console.print(f"[dim]Resuming session: {session.session_id}[/dim]")
if prompt:
prompt += "\nYou are resuming a previous session."
diff --git a/clawteam/spawn/session_capture.py b/clawteam/spawn/session_capture.py
new file mode 100644
index 00000000..2739d4bf
--- /dev/null
+++ b/clawteam/spawn/session_capture.py
@@ -0,0 +1,234 @@
+"""Facade for client-specific resumable session capture."""
+
+from __future__ import annotations
+
+import os
+import threading
+import time
+
+from clawteam.spawn.command_validation import normalize_spawn_command
+from clawteam.spawn.session_locators import (
+ CapturedSession,
+ CurrentSessionHint,
+ PreparedSession,
+ SessionContext,
+ discover_codex_session,
+ locator_for_client,
+ locator_for_command,
+ locators,
+)
+from clawteam.spawn.session_locators.base import CONFIDENCE_RANK, now_iso
+from clawteam.spawn.sessions import SessionStore
+
+SessionCapture = PreparedSession
+
+
+def prepare_session_capture(
+ command: list[str],
+ *,
+ team_name: str,
+ agent_name: str,
+ cwd: str | None = None,
+ prompt: str | None = None,
+ task_id: str = "",
+) -> PreparedSession:
+ """Prepare a command so its native client session can be captured."""
+ context = _context(
+ team_name=team_name,
+ agent_name=agent_name,
+ cwd=cwd,
+ prompt=prompt,
+ task_id=task_id,
+ )
+ locator = locator_for_command(command)
+ if locator is None:
+ return PreparedSession(
+ command=list(command),
+ team_name=team_name,
+ agent_name=agent_name,
+ cwd=cwd or "",
+ hint=context.hint,
+ )
+ prepared = locator.prepare(command, context)
+ prepared.team_name = team_name
+ prepared.agent_name = agent_name
+ return prepared
+
+
+def persist_spawned_session(
+ capture: PreparedSession,
+ *,
+ team_name: str | None = None,
+ agent_name: str | None = None,
+ command: list[str] | None = None,
+ timeout_seconds: float = 8.0,
+) -> str:
+ """Persist a spawned session id under ~/.clawteam/sessions when possible."""
+ if not capture.client:
+ return ""
+
+ context = SessionContext(
+ team_name=team_name or capture.team_name,
+ agent_name=agent_name or capture.agent_name,
+ cwd=capture.cwd,
+ hint=capture.hint,
+ started_at=capture.started_at,
+ allow_environment=False,
+ )
+ if not context.team_name or not context.agent_name:
+ return ""
+
+ locator = locator_for_client(capture.client)
+ if locator is None:
+ return ""
+
+ if capture.async_capture and not capture.session_id:
+ thread = threading.Thread(
+ target=_capture_async,
+ kwargs={
+ "locator_client": capture.client,
+ "capture": capture,
+ "context": context,
+ "command": command or capture.command,
+ "timeout_seconds": timeout_seconds,
+ },
+ daemon=True,
+ )
+ thread.start()
+ return ""
+
+ captured = locator.capture(capture, context)
+ if captured is None:
+ return ""
+ _save_captured(context, captured, command or capture.command)
+ return captured.session_id
+
+
+def save_current_agent_session(
+ team_name: str,
+ agent_name: str,
+ *,
+ cwd: str | None = None,
+) -> str:
+ """Persist the current leader agent session id."""
+ context = SessionContext(team_name=team_name, agent_name=agent_name, cwd=cwd or os.getcwd())
+ preferred_clients = []
+ if os.environ.get("CODEX_THREAD_ID") or os.environ.get("CODEX_SESSION_ID"):
+ preferred_clients.append("codex")
+ if os.environ.get("CLAUDE_CODE_SESSION") or os.environ.get("CLAUDE_SESSION_ID"):
+ preferred_clients.append("claude")
+ for locator in [*(locator_for_client(c) for c in preferred_clients), *locators()]:
+ if locator is None:
+ continue
+ captured = locator.current_session(context)
+ if captured is None:
+ continue
+ _save_captured(context, captured, [])
+ return captured.session_id
+ return ""
+
+
+def build_resume_command(
+ command: list[str],
+ session_id: str,
+ client: str | None = None,
+) -> list[str]:
+ """Return a CLI-specific command that resumes a stored session id."""
+ if not session_id:
+ return list(command)
+ locator = locator_for_client(client or "") if client else locator_for_command(command)
+ if locator is None:
+ return list(command)
+ if client and not locator.matches(command):
+ command = [_default_command_for_client(locator.client)]
+ return locator.resume_command(command, session_id)
+
+
+def _capture_async(
+ *,
+ locator_client: str,
+ capture: PreparedSession,
+ context: SessionContext,
+ command: list[str],
+ timeout_seconds: float,
+) -> None:
+ locator = locator_for_client(locator_client)
+ if locator is None:
+ return
+ deadline = time.monotonic() + max(timeout_seconds, 0.0)
+ while True:
+ captured = locator.capture(capture, context)
+ if captured is not None:
+ _save_captured(context, captured, command)
+ return
+ if time.monotonic() >= deadline:
+ return
+ time.sleep(0.4)
+
+
+def _save_captured(
+ context: SessionContext,
+ captured: CapturedSession,
+ command: list[str],
+) -> None:
+ if not captured.session_id:
+ return
+ store = SessionStore(context.team_name)
+ existing = store.load(context.agent_name)
+ if existing and existing.session_id and not _should_overwrite(existing.state, captured.confidence):
+ return
+ store.save(
+ agent_name=context.agent_name,
+ session_id=captured.session_id,
+ state={
+ "client": captured.client,
+ "source": captured.source,
+ "cwd": captured.cwd or context.cwd,
+ "command": command,
+ "confidence": captured.confidence,
+ "capturedAt": now_iso(),
+ },
+ )
+
+
+def _should_overwrite(existing_state: dict, new_confidence: str) -> bool:
+ old = str(existing_state.get("confidence") or "")
+ return CONFIDENCE_RANK.get(new_confidence, 0) >= CONFIDENCE_RANK.get(old, 0)
+
+
+def _context(
+ *,
+ team_name: str,
+ agent_name: str,
+ cwd: str | None,
+ prompt: str | None,
+ task_id: str,
+) -> SessionContext:
+ return SessionContext(
+ team_name=team_name,
+ agent_name=agent_name,
+ cwd=cwd or "",
+ hint=CurrentSessionHint.from_prompt(
+ team_name=team_name,
+ agent_name=agent_name,
+ prompt=prompt,
+ task_id=task_id,
+ ),
+ allow_environment=False,
+ )
+
+
+def client_for_command(command: list[str]) -> str:
+ locator = locator_for_command(normalize_spawn_command(command))
+ return locator.client if locator else ""
+
+
+def _default_command_for_client(client: str) -> str:
+ return {
+ "claude": "claude",
+ "codex": "codex",
+ "gemini": "gemini",
+ "opencode": "opencode",
+ "openclaw": "openclaw",
+ "nanobot": "nanobot",
+ }.get(client, client)
diff --git a/clawteam/spawn/session_locators/__init__.py b/clawteam/spawn/session_locators/__init__.py
new file mode 100644
index 00000000..8542ead8
--- /dev/null
+++ b/clawteam/spawn/session_locators/__init__.py
@@ -0,0 +1,61 @@
+"""Registry for client-specific session locators."""
+
+from __future__ import annotations
+
+from clawteam.spawn.command_validation import normalize_spawn_command
+
+from .base import CapturedSession, CurrentSessionHint, PreparedSession, SessionContext, SessionLocator
+from .claude import ClaudeSessionLocator
+from .codex import CodexSessionLocator, discover_codex_session
+from .gemini import GeminiSessionLocator
+from .nanobot import NanobotSessionLocator
+from .openclaw import OpenClawSessionLocator
+from .opencode import OpenCodeSessionLocator
+
+_LOCATORS: list[SessionLocator] = [
+ ClaudeSessionLocator(),
+ CodexSessionLocator(),
+ GeminiSessionLocator(),
+ OpenCodeSessionLocator(),
+ OpenClawSessionLocator(),
+ NanobotSessionLocator(),
+]
+
+
+def locators() -> list[SessionLocator]:
+ return list(_LOCATORS)
+
+
+def locator_for_command(command: list[str]) -> SessionLocator | None:
+ normalized = normalize_spawn_command(command)
+ for locator in _LOCATORS:
+ if locator.matches(normalized):
+ return locator
+ return None
+
+
+def locator_for_client(client: str) -> SessionLocator | None:
+ normalized = client.strip().lower()
+ aliases = {
+ "claude-code": "claude",
+ "codex-cli": "codex",
+ "gemini-cli": "gemini",
+ }
+ normalized = aliases.get(normalized, normalized)
+ for locator in _LOCATORS:
+ if locator.client == normalized:
+ return locator
+ return None
+
+
+__all__ = [
+ "CapturedSession",
+ "CurrentSessionHint",
+ "PreparedSession",
+ "SessionContext",
+ "SessionLocator",
+ "discover_codex_session",
+ "locator_for_client",
+ "locator_for_command",
+ "locators",
+]
diff --git a/clawteam/spawn/session_locators/base.py b/clawteam/spawn/session_locators/base.py
new file mode 100644
index 00000000..07620700
--- /dev/null
+++ b/clawteam/spawn/session_locators/base.py
@@ -0,0 +1,192 @@
+"""Common types and helpers for client-specific session capture."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import time
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Protocol
+
+
+CONFIDENCE_RANK = {
+ "latest": 10,
+ "hinted": 20,
+ "exact": 30,
+}
+
+
+@dataclass(frozen=True)
+class CurrentSessionHint:
+ """Metadata that helps match a spawned prompt to a native client session."""
+
+ team_name: str
+ agent_name: str
+ task_id: str = ""
+ prompt_text: str = ""
+ prompt_fingerprint: str = ""
+
+ @classmethod
+ def from_prompt(
+ cls,
+ *,
+ team_name: str,
+ agent_name: str,
+ prompt: str | None = None,
+ task_id: str = "",
+ ) -> "CurrentSessionHint":
+ text = normalize_message_text(prompt)
+ fingerprint = hashlib.sha256(text.encode("utf-8")).hexdigest() if text else ""
+ return cls(
+ team_name=team_name,
+ agent_name=agent_name,
+ task_id=task_id,
+ prompt_text=text,
+ prompt_fingerprint=fingerprint,
+ )
+
+ def tokens(self) -> list[str]:
+ return [value for value in (self.team_name, self.agent_name, self.task_id) if value]
+
+
+@dataclass
+class SessionContext:
+ team_name: str
+ agent_name: str
+ cwd: str = ""
+ hint: CurrentSessionHint | None = None
+ started_at: float = field(default_factory=time.time)
+ allow_environment: bool = True
+
+
+@dataclass
+class PreparedSession:
+ command: list[str]
+ team_name: str = ""
+ agent_name: str = ""
+ client: str = ""
+ session_id: str = ""
+ source: str = ""
+ confidence: str = ""
+ cwd: str = ""
+ started_at: float = field(default_factory=time.time)
+ async_capture: bool = False
+ hint: CurrentSessionHint | None = None
+
+
+@dataclass(frozen=True)
+class CapturedSession:
+ session_id: str
+ client: str
+ source: str
+ confidence: str
+ cwd: str = ""
+
+
+class SessionLocator(Protocol):
+ client: str
+
+ def matches(self, command: list[str]) -> bool:
+ ...
+
+ def prepare(self, command: list[str], context: SessionContext) -> PreparedSession:
+ ...
+
+ def capture(self, prepared: PreparedSession, context: SessionContext) -> CapturedSession | None:
+ ...
+
+ def current_session(self, context: SessionContext) -> CapturedSession | None:
+ ...
+
+ def resume_command(self, command: list[str], session_id: str) -> list[str]:
+ ...
+
+
+def option_value(command: list[str], option: str) -> str:
+ for i, item in enumerate(command):
+ if item == option and i + 1 < len(command):
+ return command[i + 1]
+ if item.startswith(option + "="):
+ return item.split("=", 1)[1]
+ return ""
+
+
+def has_any(command: list[str], options: set[str]) -> bool:
+ return any(item in options for item in command)
+
+
+def normalize_message_text(text: str | None) -> str:
+ if not text:
+ return ""
+ return re.sub(r"\s+", " ", text).strip().lower()[:4000]
+
+
+def same_path(left: str | Path, right: str | Path) -> bool:
+ try:
+ return Path(left).expanduser().resolve() == Path(right).expanduser().resolve()
+ except Exception:
+ return str(left) == str(right)
+
+
+def timestamp_to_epoch(value: Any) -> float:
+ if not value:
+ return 0.0
+ try:
+ text = str(value)
+ if text.endswith("Z"):
+ text = text[:-1] + "+00:00"
+ return datetime.fromisoformat(text).timestamp()
+ except Exception:
+ return 0.0
+
+
+def now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def safe_json_load(path: Path) -> Any:
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return None
+
+
+def first_json_line(path: Path) -> dict[str, Any]:
+ try:
+ first = path.read_text(encoding="utf-8").split("\n", 1)[0]
+ payload = json.loads(first)
+ return payload if isinstance(payload, dict) else {}
+ except Exception:
+ return {}
+
+
+def recent_files(root: Path, pattern: str, *, since: float = 0.0, limit: int = 80) -> list[Path]:
+ if not root.exists():
+ return []
+ threshold = since - 10 if since else 0
+ paths: list[Path] = []
+ for path in root.rglob(pattern):
+ try:
+ if path.is_file() and path.stat().st_mtime >= threshold:
+ paths.append(path)
+ except OSError:
+ continue
+ return sorted(paths, key=lambda p: p.stat().st_mtime, reverse=True)[:limit]
+
+
+def command_basename(command: list[str]) -> str:
+ if not command:
+ return ""
+ return Path(command[0]).name.lower()
+
+
+def env_session(*names: str) -> str:
+ for name in names:
+ value = os.environ.get(name)
+ if value and value.strip():
+ return value.strip()
+ return ""
diff --git a/clawteam/spawn/session_locators/claude.py b/clawteam/spawn/session_locators/claude.py
new file mode 100644
index 00000000..d309979e
--- /dev/null
+++ b/clawteam/spawn/session_locators/claude.py
@@ -0,0 +1,148 @@
+"""Claude Code session locator."""
+
+from __future__ import annotations
+
+import json
+import re
+import uuid
+from pathlib import Path
+
+from clawteam.spawn.adapters import is_claude_command
+from clawteam.spawn.command_validation import normalize_spawn_command
+
+from .base import (
+ CapturedSession,
+ PreparedSession,
+ SessionContext,
+ env_session,
+ has_any,
+ option_value,
+ timestamp_to_epoch,
+)
+
+
+class ClaudeSessionLocator:
+ client = "claude"
+
+ def matches(self, command: list[str]) -> bool:
+ return is_claude_command(normalize_spawn_command(command))
+
+ def prepare(self, command: list[str], context: SessionContext) -> PreparedSession:
+ normalized = normalize_spawn_command(command)
+ existing = option_value(normalized, "--session-id")
+ if existing:
+ return PreparedSession(
+ command=list(command),
+ client=self.client,
+ session_id=existing,
+ source="provided",
+ confidence="exact",
+ cwd=context.cwd,
+ started_at=context.started_at,
+ hint=context.hint,
+ )
+ resumed = option_value(normalized, "--resume") or option_value(normalized, "-r")
+ if resumed:
+ return PreparedSession(
+ command=list(command),
+ client=self.client,
+ session_id=resumed,
+ source="provided",
+ confidence="exact",
+ cwd=context.cwd,
+ started_at=context.started_at,
+ hint=context.hint,
+ )
+ if has_any(normalized, {"--continue", "-c", "--no-session-persistence"}):
+ return PreparedSession(
+ command=list(command),
+ client=self.client,
+ cwd=context.cwd,
+ started_at=context.started_at,
+ hint=context.hint,
+ )
+
+ session_id = str(uuid.uuid4())
+ return PreparedSession(
+ command=[*command, "--session-id", session_id],
+ client=self.client,
+ session_id=session_id,
+ source="generated",
+ confidence="exact",
+ cwd=context.cwd,
+ started_at=context.started_at,
+ hint=context.hint,
+ )
+
+ def capture(self, prepared: PreparedSession, context: SessionContext) -> CapturedSession | None:
+ if prepared.session_id:
+ return CapturedSession(
+ session_id=prepared.session_id,
+ client=self.client,
+ source=prepared.source or "prepared",
+ confidence=prepared.confidence or "exact",
+ cwd=context.cwd,
+ )
+ return self.current_session(context)
+
+ def current_session(self, context: SessionContext) -> CapturedSession | None:
+ if context.allow_environment:
+ session_id = env_session("CLAUDE_CODE_SESSION", "CLAUDE_SESSION_ID")
+ if session_id:
+ return CapturedSession(session_id, self.client, "environment", "exact", context.cwd)
+
+ project_dir = _claude_project_dir(context.cwd)
+ if not project_dir:
+ return None
+ sessions = sorted(project_dir.glob("*.jsonl"), key=_claude_session_sort_key, reverse=True)
+ if not sessions:
+ return None
+ session_path = sessions[0]
+ return CapturedSession(session_path.stem, self.client, "transcript", "latest", context.cwd)
+
+ def resume_command(self, command: list[str], session_id: str) -> list[str]:
+ normalized = normalize_spawn_command(command)
+ if option_value(normalized, "--resume") or option_value(normalized, "-r"):
+ return list(command)
+ return [*command, "--resume", session_id]
+
+
+def _encode_claude_project_dir(workspace: Path) -> str:
+ return re.sub(r"[^A-Za-z0-9-]", "-", str(workspace))
+
+
+def _claude_project_dir(cwd: str) -> Path | None:
+ if not cwd:
+ return None
+ projects = Path.home() / ".claude" / "projects"
+ if not projects.exists():
+ return None
+ encoded = _encode_claude_project_dir(Path(cwd).resolve())
+ direct = projects / encoded
+ if direct.exists():
+ return direct
+ for candidate in projects.iterdir():
+ if candidate.is_dir() and encoded in candidate.name:
+ return candidate
+ return None
+
+
+def _claude_session_sort_key(path: Path) -> tuple[float, float]:
+ try:
+ lines = path.read_text(encoding="utf-8").splitlines()
+ except OSError:
+ return (0.0, 0.0)
+ for line in reversed(lines):
+ if not line.strip():
+ continue
+ try:
+ payload = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ ts = timestamp_to_epoch(payload.get("timestamp") or payload.get("createdAt"))
+ if ts:
+ return (ts, path.stat().st_mtime)
+ try:
+ return (0.0, path.stat().st_mtime)
+ except OSError:
+ return (0.0, 0.0)
diff --git a/clawteam/spawn/session_locators/codex.py b/clawteam/spawn/session_locators/codex.py
new file mode 100644
index 00000000..e9c87eda
--- /dev/null
+++ b/clawteam/spawn/session_locators/codex.py
@@ -0,0 +1,160 @@
+"""Codex CLI session locator."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from clawteam.spawn.adapters import is_codex_command
+from clawteam.spawn.command_validation import normalize_spawn_command
+
+from .base import (
+ CapturedSession,
+ CurrentSessionHint,
+ PreparedSession,
+ SessionContext,
+ env_session,
+ first_json_line,
+ option_value,
+ recent_files,
+ same_path,
+ timestamp_to_epoch,
+)
+
+
+class CodexSessionLocator:
+ client = "codex"
+
+ def matches(self, command: list[str]) -> bool:
+ return is_codex_command(normalize_spawn_command(command))
+
+ def prepare(self, command: list[str], context: SessionContext) -> PreparedSession:
+ session_id = _codex_resume_session_id(normalize_spawn_command(command))
+ return PreparedSession(
+ command=list(command),
+ client=self.client,
+ session_id=session_id,
+ source="provided" if session_id else "",
+ confidence="exact" if session_id else "",
+ cwd=context.cwd,
+ started_at=context.started_at,
+ async_capture=not bool(session_id),
+ hint=context.hint,
+ )
+
+ def capture(self, prepared: PreparedSession, context: SessionContext) -> CapturedSession | None:
+ if prepared.session_id:
+ return CapturedSession(prepared.session_id, self.client, "provided", "exact", context.cwd)
+ return self.current_session(context)
+
+ def current_session(self, context: SessionContext) -> CapturedSession | None:
+ current = env_session("CODEX_THREAD_ID", "CODEX_SESSION_ID") if context.allow_environment else ""
+ workspace = Path(context.cwd).resolve() if context.cwd else None
+ candidates = _codex_session_candidates(since=0.0 if context.allow_environment else context.started_at)
+ if current:
+ for path in candidates:
+ meta = _codex_meta(path)
+ if meta.get("id") == current and _meta_matches_workspace(meta, workspace):
+ return CapturedSession(current, self.client, "environment", "exact", context.cwd)
+ return CapturedSession(current, self.client, "environment", "exact", context.cwd)
+
+ scored: list[tuple[int, float, str]] = []
+ for path in candidates:
+ meta = _codex_meta(path)
+ session_id = str(meta.get("id") or "")
+ if not session_id or not _meta_matches_workspace(meta, workspace):
+ continue
+ raw = _read_prefix(path)
+ score = 10
+ source = "transcript"
+ confidence = "latest"
+ if context.hint and all(token in raw for token in context.hint.tokens()):
+ score = 20
+ confidence = "hinted"
+ sort_ts = timestamp_to_epoch(meta.get("timestamp")) or path.stat().st_mtime
+ scored.append((score, sort_ts, f"{session_id}|{source}|{confidence}"))
+ if not scored:
+ return None
+ _, _, packed = max(scored, key=lambda item: (item[0], item[1]))
+ session_id, source, confidence = packed.split("|", 2)
+ return CapturedSession(session_id, self.client, source, confidence, context.cwd)
+
+ def resume_command(self, command: list[str], session_id: str) -> list[str]:
+ normalized = normalize_spawn_command(command)
+ if len(normalized) >= 2 and normalized[1] in {"resume", "fork"}:
+ return list(command)
+ return [*command, "resume", session_id]
+
+
+def discover_codex_session(
+ *,
+ team_name: str,
+ agent_name: str,
+ cwd: str,
+ since: float,
+ timeout_seconds: float = 8.0,
+) -> str:
+ import time
+
+ hint_context = SessionContext(
+ team_name=team_name,
+ agent_name=agent_name,
+ cwd=cwd,
+ hint=CurrentSessionHint.from_prompt(team_name=team_name, agent_name=agent_name),
+ started_at=since,
+ allow_environment=False,
+ )
+ deadline = time.monotonic() + max(timeout_seconds, 0.0)
+ locator = CodexSessionLocator()
+ while True:
+ found = locator.current_session(hint_context)
+ if found:
+ return found.session_id
+ if time.monotonic() >= deadline:
+ return ""
+ time.sleep(0.4)
+
+
+def _codex_session_id(path: Path) -> str:
+ return str(_codex_meta(path).get("id") or "")
+
+
+def _codex_meta(path: Path) -> dict:
+ first = first_json_line(path)
+ if first.get("type") != "session_meta":
+ return {}
+ payload = first.get("payload")
+ return payload if isinstance(payload, dict) else {}
+
+
+def _codex_session_candidates(*, since: float = 0.0) -> list[Path]:
+ return recent_files(Path.home() / ".codex" / "sessions", "*.jsonl", since=since, limit=100)
+
+
+def _meta_matches_workspace(meta: dict, workspace: Path | None) -> bool:
+ if workspace is None:
+ return True
+ cwd = meta.get("cwd")
+ return isinstance(cwd, str) and same_path(cwd, workspace)
+
+
+def _read_prefix(path: Path, max_lines: int = 120) -> str:
+ parts: list[str] = []
+ try:
+ with path.open("r", encoding="utf-8") as fh:
+ for i, line in enumerate(fh):
+ parts.append(line)
+ if i >= max_lines:
+ break
+ except OSError:
+ return ""
+ return "".join(parts)
+
+
+def _codex_resume_session_id(command: list[str]) -> str:
+ if len(command) < 2 or command[1] != "resume":
+ return ""
+ for item in command[2:]:
+ if item.startswith("-"):
+ continue
+ return item
+ return ""
diff --git a/clawteam/spawn/session_locators/gemini.py b/clawteam/spawn/session_locators/gemini.py
new file mode 100644
index 00000000..5692d3c5
--- /dev/null
+++ b/clawteam/spawn/session_locators/gemini.py
@@ -0,0 +1,98 @@
+"""Gemini CLI session locator."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+from clawteam.spawn.adapters import is_gemini_command
+from clawteam.spawn.command_validation import normalize_spawn_command
+
+from .base import CapturedSession, PreparedSession, SessionContext, option_value, safe_json_load, same_path, timestamp_to_epoch
+
+
+class GeminiSessionLocator:
+ client = "gemini"
+
+ def matches(self, command: list[str]) -> bool:
+ return is_gemini_command(normalize_spawn_command(command))
+
+ def prepare(self, command: list[str], context: SessionContext) -> PreparedSession:
+ session_id = option_value(normalize_spawn_command(command), "--resume")
+ return PreparedSession(
+ command=list(command),
+ client=self.client,
+ session_id="" if session_id == "latest" else session_id,
+ source="provided" if session_id and session_id != "latest" else "",
+ confidence="exact" if session_id and session_id != "latest" else "",
+ cwd=context.cwd,
+ started_at=context.started_at,
+ async_capture=not bool(session_id),
+ hint=context.hint,
+ )
+
+ def capture(self, prepared: PreparedSession, context: SessionContext) -> CapturedSession | None:
+ if prepared.session_id:
+ return CapturedSession(prepared.session_id, self.client, "provided", "exact", context.cwd)
+ return self.current_session(context)
+
+ def current_session(self, context: SessionContext) -> CapturedSession | None:
+ cwd = Path(context.cwd).resolve() if context.cwd else None
+ sessions: list[Path] = []
+ tmp_root = _gemini_home() / "tmp"
+ if not tmp_root.exists() or cwd is None:
+ return None
+ for project_dir in tmp_root.iterdir():
+ marker = project_dir / ".project_root"
+ if not marker.is_file():
+ continue
+ try:
+ if not same_path(marker.read_text(encoding="utf-8").strip(), cwd):
+ continue
+ except OSError:
+ continue
+ sessions.extend((project_dir / "chats").glob("*.json"))
+ sessions = sorted(sessions, key=_gemini_session_sort_key, reverse=True)
+ for path in sessions:
+ payload = safe_json_load(path)
+ if not isinstance(payload, dict):
+ continue
+ session_id = payload.get("sessionId")
+ if isinstance(session_id, str) and session_id:
+ return CapturedSession(session_id, self.client, "transcript", "latest", context.cwd)
+ return None
+
+ def resume_command(self, command: list[str], session_id: str) -> list[str]:
+ normalized = normalize_spawn_command(command)
+ if option_value(normalized, "--resume"):
+ return list(command)
+ return [*command, "--resume", session_id]
+
+
+def _gemini_home() -> Path:
+ cli_home = os.environ.get("GEMINI_CLI_HOME")
+ if cli_home:
+ return Path(cli_home).expanduser() / ".gemini"
+ legacy = os.environ.get("GEMINI_HOME")
+ if legacy:
+ return Path(legacy).expanduser()
+ return Path.home() / ".gemini"
+
+
+def _gemini_session_sort_key(path: Path) -> tuple[float, float]:
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ try:
+ return (0.0, path.stat().st_mtime)
+ except OSError:
+ return (0.0, 0.0)
+ for field in ("lastUpdated", "startTime"):
+ ts = timestamp_to_epoch(payload.get(field))
+ if ts:
+ return (ts, path.stat().st_mtime)
+ try:
+ return (0.0, path.stat().st_mtime)
+ except OSError:
+ return (0.0, 0.0)
diff --git a/clawteam/spawn/session_locators/nanobot.py b/clawteam/spawn/session_locators/nanobot.py
new file mode 100644
index 00000000..730688e7
--- /dev/null
+++ b/clawteam/spawn/session_locators/nanobot.py
@@ -0,0 +1,53 @@
+"""Nanobot session locator."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from clawteam.spawn.adapters import is_nanobot_command
+from clawteam.spawn.command_validation import normalize_spawn_command
+
+from .base import CapturedSession, PreparedSession, SessionContext, option_value
+
+
+class NanobotSessionLocator:
+ client = "nanobot"
+
+ def matches(self, command: list[str]) -> bool:
+ return is_nanobot_command(normalize_spawn_command(command))
+
+ def prepare(self, command: list[str], context: SessionContext) -> PreparedSession:
+ session_id = option_value(normalize_spawn_command(command), "--session")
+ return PreparedSession(
+ command=list(command),
+ client=self.client,
+ session_id=session_id,
+ source="provided" if session_id else "",
+ confidence="exact" if session_id else "",
+ cwd=context.cwd,
+ started_at=context.started_at,
+ async_capture=not bool(session_id),
+ hint=context.hint,
+ )
+
+ def capture(self, prepared: PreparedSession, context: SessionContext) -> CapturedSession | None:
+ if prepared.session_id:
+ return CapturedSession(prepared.session_id, self.client, "provided", "exact", context.cwd)
+ return self.current_session(context)
+
+ def current_session(self, context: SessionContext) -> CapturedSession | None:
+ home = Path(os.environ.get("NANOBOT_HOME", Path.home() / ".nanobot")).expanduser()
+ sessions_dir = home / "workspace" / "sessions"
+ if not sessions_dir.exists():
+ return None
+ sessions = sorted(sessions_dir.glob("*.jsonl"), key=lambda path: path.stat().st_mtime, reverse=True)
+ if not sessions:
+ return None
+ return CapturedSession(sessions[0].stem, self.client, "transcript", "latest", context.cwd)
+
+ def resume_command(self, command: list[str], session_id: str) -> list[str]:
+ normalized = normalize_spawn_command(command)
+ if option_value(normalized, "--session"):
+ return list(command)
+ return [*command, "--session", session_id]
diff --git a/clawteam/spawn/session_locators/openclaw.py b/clawteam/spawn/session_locators/openclaw.py
new file mode 100644
index 00000000..153bb860
--- /dev/null
+++ b/clawteam/spawn/session_locators/openclaw.py
@@ -0,0 +1,70 @@
+"""OpenClaw session locator."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from clawteam.spawn.adapters import is_openclaw_command
+from clawteam.spawn.command_validation import normalize_spawn_command
+
+from .base import CapturedSession, PreparedSession, SessionContext, first_json_line, option_value, safe_json_load, same_path
+
+
+class OpenClawSessionLocator:
+ client = "openclaw"
+
+ def matches(self, command: list[str]) -> bool:
+ return is_openclaw_command(normalize_spawn_command(command))
+
+ def prepare(self, command: list[str], context: SessionContext) -> PreparedSession:
+ normalized = normalize_spawn_command(command)
+ session_id = option_value(normalized, "--session-id") or option_value(normalized, "--session")
+ return PreparedSession(
+ command=list(command),
+ client=self.client,
+ session_id=session_id,
+ source="provided" if session_id else "",
+ confidence="exact" if session_id else "",
+ cwd=context.cwd,
+ started_at=context.started_at,
+ async_capture=not bool(session_id),
+ hint=context.hint,
+ )
+
+ def capture(self, prepared: PreparedSession, context: SessionContext) -> CapturedSession | None:
+ if prepared.session_id:
+ return CapturedSession(prepared.session_id, self.client, "provided", "exact", context.cwd)
+ return self.current_session(context)
+
+ def current_session(self, context: SessionContext) -> CapturedSession | None:
+ home = Path.home() / ".openclaw"
+ agents_dir = home / "agents"
+ if not agents_dir.exists():
+ return None
+ matches: list[tuple[float, str]] = []
+ for session_path in agents_dir.glob("*/sessions/*.jsonl"):
+ if context.cwd:
+ header = first_json_line(session_path)
+ cwd = header.get("cwd")
+ if isinstance(cwd, str) and not same_path(cwd, context.cwd):
+ continue
+ session_id = session_path.stem
+ store = safe_json_load(session_path.parent / "sessions.json")
+ if isinstance(store, dict):
+ for item in store.values():
+ if isinstance(item, dict) and item.get("sessionId") == session_path.stem:
+ session_id = str(item.get("sessionId"))
+ break
+ matches.append((session_path.stat().st_mtime, session_id))
+ if not matches:
+ return None
+ _, session_id = max(matches)
+ return CapturedSession(session_id, self.client, "transcript", "latest", context.cwd)
+
+ def resume_command(self, command: list[str], session_id: str) -> list[str]:
+ normalized = normalize_spawn_command(command)
+ if option_value(normalized, "--session-id") or option_value(normalized, "--session"):
+ return list(command)
+ if "tui" in normalized:
+ return [*command, "--session", f"agent:main:resume:{session_id}"]
+ return [*command, "--session-id", session_id]
diff --git a/clawteam/spawn/session_locators/opencode.py b/clawteam/spawn/session_locators/opencode.py
new file mode 100644
index 00000000..49aa1d9a
--- /dev/null
+++ b/clawteam/spawn/session_locators/opencode.py
@@ -0,0 +1,74 @@
+"""OpenCode session locator."""
+
+from __future__ import annotations
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+from clawteam.spawn.adapters import is_opencode_command
+from clawteam.spawn.command_validation import normalize_spawn_command
+
+from .base import CapturedSession, PreparedSession, SessionContext, option_value, same_path
+
+
+class OpenCodeSessionLocator:
+ client = "opencode"
+
+ def matches(self, command: list[str]) -> bool:
+ return is_opencode_command(normalize_spawn_command(command))
+
+ def prepare(self, command: list[str], context: SessionContext) -> PreparedSession:
+ session_id = option_value(normalize_spawn_command(command), "--session")
+ return PreparedSession(
+ command=list(command),
+ client=self.client,
+ session_id=session_id,
+ source="provided" if session_id else "",
+ confidence="exact" if session_id else "",
+ cwd=context.cwd,
+ started_at=context.started_at,
+ async_capture=not bool(session_id),
+ hint=context.hint,
+ )
+
+ def capture(self, prepared: PreparedSession, context: SessionContext) -> CapturedSession | None:
+ if prepared.session_id:
+ return CapturedSession(prepared.session_id, self.client, "provided", "exact", context.cwd)
+ return self.current_session(context)
+
+ def current_session(self, context: SessionContext) -> CapturedSession | None:
+ binary = shutil.which("opencode")
+ if not binary or not context.cwd:
+ return None
+ result = subprocess.run(
+ [binary, "session", "list", "--format", "json"],
+ cwd=context.cwd,
+ text=True,
+ capture_output=True,
+ timeout=10,
+ check=False,
+ )
+ if result.returncode != 0:
+ return None
+ try:
+ payload = json.loads(result.stdout)
+ except json.JSONDecodeError:
+ return None
+ matches = []
+ for item in payload if isinstance(payload, list) else []:
+ session_id = item.get("id")
+ directory = item.get("directory")
+ if isinstance(session_id, str) and isinstance(directory, str) and same_path(directory, context.cwd):
+ matches.append(item)
+ matches.sort(key=lambda item: item.get("updated", 0), reverse=True)
+ if not matches:
+ return None
+ return CapturedSession(matches[0]["id"], self.client, "client-list", "latest", context.cwd)
+
+ def resume_command(self, command: list[str], session_id: str) -> list[str]:
+ normalized = normalize_spawn_command(command)
+ if option_value(normalized, "--session"):
+ return list(command)
+ return [*command, "--session", session_id]
diff --git a/clawteam/spawn/subprocess_backend.py b/clawteam/spawn/subprocess_backend.py
index 055dd434..7948f431 100644
--- a/clawteam/spawn/subprocess_backend.py
+++ b/clawteam/spawn/subprocess_backend.py
@@ -15,6 +15,7 @@
build_resume_command,
)
from clawteam.spawn.runtime_notification import render_runtime_notification
+from clawteam.spawn.session_capture import prepare_session_capture, persist_spawned_session
from clawteam.team.mailbox import MailboxManager
from clawteam.team.models import MessageType, get_data_dir
@@ -69,8 +70,15 @@ def spawn(
if os.path.isabs(clawteam_bin):
spawn_env.setdefault("CLAWTEAM_BIN", clawteam_bin)
- prepared = self._adapter.prepare_command(
+ session_capture = prepare_session_capture(
command,
+ team_name=team_name,
+ agent_name=agent_name,
+ cwd=cwd,
+ prompt=prompt,
+ )
+ prepared = self._adapter.prepare_command(
+ session_capture.command,
prompt=prompt,
cwd=cwd,
skip_permissions=skip_permissions,
@@ -148,6 +156,12 @@ def spawn(
pid=process.pid,
command=list(final_command),
)
+ persist_spawned_session(
+ session_capture,
+ team_name=team_name,
+ agent_name=agent_name,
+ command=list(final_command),
+ )
return f"Agent '{agent_name}' spawned as subprocess (pid={process.pid})"
diff --git a/clawteam/spawn/tmux_backend.py b/clawteam/spawn/tmux_backend.py
index c89748a3..d9195e35 100644
--- a/clawteam/spawn/tmux_backend.py
+++ b/clawteam/spawn/tmux_backend.py
@@ -26,6 +26,7 @@
from clawteam.spawn.command_validation import validate_spawn_command
from clawteam.spawn.keepalive import build_keepalive_shell_command, build_resume_command
from clawteam.spawn.runtime_notification import render_runtime_notification
+from clawteam.spawn.session_capture import prepare_session_capture, persist_spawned_session
from clawteam.team.models import get_data_dir
_SHELL_ENV_KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
@@ -86,8 +87,15 @@ def spawn(
if os.path.isabs(clawteam_bin):
env_vars.setdefault("CLAWTEAM_BIN", clawteam_bin)
- prepared = self._adapter.prepare_command(
+ session_capture = prepare_session_capture(
command,
+ team_name=team_name,
+ agent_name=agent_name,
+ cwd=cwd,
+ prompt=prompt,
+ )
+ prepared = self._adapter.prepare_command(
+ session_capture.command,
prompt=prompt,
cwd=cwd,
skip_permissions=skip_permissions,
@@ -298,6 +306,12 @@ def spawn(
pid=pane_pid,
command=list(final_command),
)
+ persist_spawned_session(
+ session_capture,
+ team_name=team_name,
+ agent_name=agent_name,
+ command=list(final_command),
+ )
# Emit AfterWorkerSpawn event
try:
diff --git a/clawteam/spawn/wsh_backend.py b/clawteam/spawn/wsh_backend.py
index 58f3a279..3a941cf5 100644
--- a/clawteam/spawn/wsh_backend.py
+++ b/clawteam/spawn/wsh_backend.py
@@ -22,6 +22,7 @@
from clawteam.spawn.command_validation import validate_spawn_command
from clawteam.spawn.keepalive import build_keepalive_shell_command, build_resume_command
from clawteam.spawn.runtime_notification import render_runtime_notification
+from clawteam.spawn.session_capture import prepare_session_capture, persist_spawned_session
from clawteam.spawn.wsh_rpc import WshRpcClient
from clawteam.team.models import get_data_dir
@@ -258,8 +259,15 @@ def spawn(
env_vars.update(env)
env_vars["PATH"] = build_spawn_path(env_vars.get("PATH", os.environ.get("PATH")))
- prepared = self._adapter.prepare_command(
+ session_capture = prepare_session_capture(
command,
+ team_name=team_name,
+ agent_name=agent_name,
+ cwd=cwd,
+ prompt=prompt,
+ )
+ prepared = self._adapter.prepare_command(
+ session_capture.command,
prompt=None,
cwd=cwd,
skip_permissions=skip_permissions,
@@ -380,6 +388,12 @@ def spawn(
pid=pane_pid,
command=list(final_command),
)
+ persist_spawned_session(
+ session_capture,
+ team_name=team_name,
+ agent_name=agent_name,
+ command=list(final_command),
+ )
return f"Agent '{agent_name}' spawned in wsh block ({block_id})"
diff --git a/clawteam/store/file.py b/clawteam/store/file.py
index 4dcb3a98..4503146f 100644
--- a/clawteam/store/file.py
+++ b/clawteam/store/file.py
@@ -98,6 +98,18 @@ def create(
task.status = TaskStatus.blocked
with self._write_lock():
self._save_unlocked(task)
+ try:
+ from clawteam.team.redis_wakeup import publish_wakeup, team_channel
+ payload = {
+ "taskId": task.id,
+ "owner": task.owner,
+ "status": task.status.value,
+ "subject": task.subject,
+ }
+ publish_wakeup(self.team_name, team_channel(self.team_name, "tasks"), "task_created", payload)
+ publish_wakeup(self.team_name, team_channel(self.team_name, "events"), "task_created", payload)
+ except Exception:
+ pass
try:
from clawteam.events.global_bus import get_event_bus
from clawteam.events.types import BeforeTaskCreate
@@ -211,6 +223,19 @@ def update(
))
except Exception:
pass
+ try:
+ from clawteam.team.redis_wakeup import publish_wakeup, team_channel
+ payload = {
+ "taskId": task.id,
+ "owner": task.owner,
+ "oldStatus": _old_status,
+ "newStatus": task.status.value,
+ "subject": task.subject,
+ }
+ publish_wakeup(self.team_name, team_channel(self.team_name, "tasks"), "task_updated", payload)
+ publish_wakeup(self.team_name, team_channel(self.team_name, "events"), "task_updated", payload)
+ except Exception:
+ pass
return task
def _acquire_lock(self, task: TaskItem, caller: str, force: bool) -> None:
diff --git a/clawteam/team/leader_watcher.py b/clawteam/team/leader_watcher.py
new file mode 100644
index 00000000..b30411e0
--- /dev/null
+++ b/clawteam/team/leader_watcher.py
@@ -0,0 +1,327 @@
+"""Leader watcher that periodically injects coordination reminders."""
+
+from __future__ import annotations
+
+import json
+import signal
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from clawteam.fileutil import atomic_write_text
+from clawteam.paths import ensure_within_root, validate_identifier
+from clawteam.team.mailbox import MailboxManager
+from clawteam.team.models import MessageType, TaskStatus, get_data_dir
+from clawteam.team.redis_wakeup import (
+ RedisWakeup,
+ agent_channel,
+ resolve_wakeup,
+ subscribe_client,
+ team_channel,
+)
+from clawteam.team.routing_policy import RuntimeEnvelope
+from clawteam.team.tasks import TaskStore
+
+
+@dataclass
+class LeaderWatchResult:
+ injected: bool
+ reason: str
+ summary: str = ""
+ evidence: list[str] = field(default_factory=list)
+ redis_event: bool = False
+
+
+class LeaderWatcher:
+ """Poll team state and wake the leader agent when action is needed."""
+
+ def __init__(
+ self,
+ team_name: str,
+ leader_name: str,
+ *,
+ interval: float = 60.0,
+ heartbeat_interval: float = 300.0,
+ redis_mode: str = "auto",
+ json_output: bool = False,
+ verbose: bool = False,
+ ):
+ validate_identifier(team_name, "team name")
+ validate_identifier(leader_name, "leader name")
+ self.team_name = team_name
+ self.leader_name = leader_name
+ self.interval = max(interval, 1.0)
+ self.heartbeat_interval = max(heartbeat_interval, 1.0)
+ self.redis_mode = redis_mode
+ self.json_output = json_output
+ self.verbose = verbose
+ self.task_store = TaskStore(team_name)
+ self.mailbox = MailboxManager(team_name)
+ self.redis: RedisWakeup = RedisWakeup(False)
+ self._running = False
+
+ def run(self) -> None:
+ """Run the blocking watcher loop."""
+ self.redis = resolve_wakeup(self.team_name, self.redis_mode)
+ self._running = True
+
+ prev_int = signal.getsignal(signal.SIGINT)
+ prev_term = signal.getsignal(signal.SIGTERM)
+
+ def _handle_signal(signum, frame):
+ self._running = False
+
+ signal.signal(signal.SIGINT, _handle_signal)
+ signal.signal(signal.SIGTERM, _handle_signal)
+ try:
+ self.check_once(reason="startup")
+ if self.redis.enabled:
+ self._run_redis_loop()
+ else:
+ self._run_poll_loop()
+ finally:
+ signal.signal(signal.SIGINT, prev_int)
+ signal.signal(signal.SIGTERM, prev_term)
+
+ def check_once(self, *, reason: str = "poll", redis_event: bool = False) -> LeaderWatchResult:
+ """Check team state once and inject if state changed or heartbeat is due."""
+ snapshot = self._collect_snapshot()
+ state = self._read_state()
+ signature = self._signature(snapshot)
+ now = time.time()
+ last_signature = str(state.get("lastSignature") or "")
+ last_heartbeat = float(state.get("lastHeartbeatAt") or 0.0)
+
+ changed = signature != last_signature
+ heartbeat_due = now - last_heartbeat >= self.heartbeat_interval
+ if not changed and not heartbeat_due:
+ return LeaderWatchResult(False, "no_change", redis_event=redis_event)
+
+ summary, evidence = self._render(snapshot, changed=changed, heartbeat_due=heartbeat_due)
+ injected, status = self._inject(summary, evidence)
+ state.update(
+ {
+ "lastSignature": signature,
+ "lastCheckAt": now,
+ "lastInjectAt": now if injected else state.get("lastInjectAt", 0.0),
+ "lastInjectStatus": status,
+ }
+ )
+ if heartbeat_due:
+ state["lastHeartbeatAt"] = now
+ self._write_state(state)
+ result = LeaderWatchResult(
+ injected=injected,
+ reason=reason if changed else "heartbeat",
+ summary=summary,
+ evidence=evidence,
+ redis_event=redis_event,
+ )
+ self._emit_result(result)
+ return result
+
+ def _run_poll_loop(self) -> None:
+ while self._running:
+ time.sleep(self.interval)
+ self.check_once(reason="poll")
+
+ def _run_redis_loop(self) -> None:
+ client = subscribe_client(self.redis.url)
+ if client is None:
+ self.redis = RedisWakeup(False, reason="redis client unavailable")
+ self._run_poll_loop()
+ return
+ pubsub = client.pubsub(ignore_subscribe_messages=True)
+ try:
+ try:
+ from clawteam.team.manager import TeamManager
+ leader_inbox = TeamManager.resolve_inbox(self.team_name, self.leader_name)
+ except Exception:
+ leader_inbox = self.leader_name
+ channels = {
+ agent_channel(self.team_name, self.leader_name),
+ agent_channel(self.team_name, leader_inbox),
+ team_channel(self.team_name, "tasks"),
+ team_channel(self.team_name, "events"),
+ }
+ pubsub.subscribe(
+ *sorted(channels),
+ )
+ while self._running:
+ try:
+ message = pubsub.get_message(timeout=self.interval)
+ except Exception:
+ self.redis = RedisWakeup(False, reason="redis subscription failed")
+ self._run_poll_loop()
+ return
+ self.check_once(reason="redis" if message else "poll", redis_event=bool(message))
+ finally:
+ try:
+ pubsub.close()
+ except Exception:
+ pass
+
+ def _collect_snapshot(self) -> dict[str, Any]:
+ tasks = self.task_store.list_tasks()
+ try:
+ from clawteam.spawn.registry import list_dead_agents
+ dead_agents = list_dead_agents(self.team_name)
+ except Exception:
+ dead_agents = []
+ leader_messages = self.mailbox.peek(self.leader_name)
+ actionable_messages = [
+ m for m in leader_messages
+ if m.from_agent != "scheduler" and not (m.content or "").startswith("Scheduler check:")
+ ]
+ completed = [t for t in tasks if t.status == TaskStatus.completed]
+ blocked = [t for t in tasks if t.status == TaskStatus.blocked]
+ in_progress = [t for t in tasks if t.status == TaskStatus.in_progress]
+ pending = [t for t in tasks if t.status == TaskStatus.pending]
+ return {
+ "total": len(tasks),
+ "completed": [_task_ref(t) for t in completed],
+ "blocked": [_task_ref(t) for t in blocked],
+ "inProgress": [_task_ref(t) for t in in_progress],
+ "pending": [_task_ref(t) for t in pending],
+ "leaderInboxCount": len(actionable_messages),
+ "deadAgents": sorted(dead_agents),
+ }
+
+ def _signature(self, snapshot: dict[str, Any]) -> str:
+ data = {
+ "completed": snapshot["completed"],
+ "blocked": snapshot["blocked"],
+ "leaderInboxCount": snapshot["leaderInboxCount"],
+ "deadAgents": snapshot["deadAgents"],
+ }
+ return json.dumps(data, sort_keys=True, ensure_ascii=False)
+
+ def _render(
+ self,
+ snapshot: dict[str, Any],
+ *,
+ changed: bool,
+ heartbeat_due: bool,
+ ) -> tuple[str, list[str]]:
+ completed_by_owner: dict[str, int] = {}
+ for task in snapshot["completed"]:
+ owner = task.get("owner") or "unassigned"
+ completed_by_owner[owner] = completed_by_owner.get(owner, 0) + 1
+ completed_text = ", ".join(
+ f"{owner} finished {count} task(s)" for owner, count in sorted(completed_by_owner.items())
+ ) or "none"
+ dead_text = ", ".join(snapshot["deadAgents"]) or "none"
+ summary = (
+ "Scheduler check:\n"
+ f"- Completed: {completed_text}\n"
+ f"- Inbox: {self.leader_name} has {snapshot['leaderInboxCount']} unread message(s)\n"
+ f"- Blocked: {len(snapshot['blocked'])}\n"
+ f"- Dead agents: {dead_text}\n\n"
+ "Recommended next action:\n"
+ f"Run `clawteam task list {self.team_name}` and "
+ f"`clawteam inbox receive {self.team_name} --agent {self.leader_name}`, "
+ "then decide next steps."
+ )
+ evidence = [
+ f"trigger: {'state_changed' if changed else 'heartbeat'}",
+ f"heartbeatDue: {heartbeat_due}",
+ f"tasks: {len(snapshot['completed'])}/{snapshot['total']} completed",
+ f"inProgress: {len(snapshot['inProgress'])}",
+ f"pending: {len(snapshot['pending'])}",
+ f"blocked: {len(snapshot['blocked'])}",
+ f"leaderInboxCount: {snapshot['leaderInboxCount']}",
+ f"deadAgents: {dead_text}",
+ ]
+ return summary, evidence
+
+ def _inject(self, summary: str, evidence: list[str]) -> tuple[bool, str]:
+ envelope = RuntimeEnvelope(
+ source="scheduler",
+ target=self.leader_name,
+ channel="coordinator",
+ priority="medium",
+ message_type="scheduler_check",
+ summary=summary,
+ evidence=evidence,
+ recommended_next_action=(
+ f"Run `clawteam task list {self.team_name}` and "
+ f"`clawteam inbox receive {self.team_name} --agent {self.leader_name}`."
+ ),
+ )
+ try:
+ from clawteam.spawn import get_backend
+ from clawteam.spawn.registry import get_registry
+ registry = get_registry(self.team_name)
+ backend_name = (registry.get(self.leader_name) or {}).get("backend", "tmux") or "tmux"
+ backend = get_backend(backend_name)
+ if hasattr(backend, "inject_runtime_message"):
+ ok, status = backend.inject_runtime_message(self.team_name, self.leader_name, envelope)
+ if ok:
+ return True, status
+ except Exception as exc:
+ status = str(exc)
+ else:
+ status = "runtime injection unsupported or failed"
+
+ try:
+ self.mailbox.send(
+ from_agent="scheduler",
+ to=self.leader_name,
+ content=summary,
+ msg_type=MessageType.message,
+ summary="Scheduler check",
+ )
+ return True, f"queued in leader inbox after injection failure: {status}"
+ except Exception as exc:
+ return False, f"runtime injection and inbox fallback failed: {exc}"
+
+ def _state_path(self) -> Path:
+ team_dir = ensure_within_root(
+ get_data_dir() / "teams",
+ validate_identifier(self.team_name, "team name"),
+ )
+ return team_dir / "leader_watch_state.json"
+
+ def _read_state(self) -> dict[str, Any]:
+ path = self._state_path()
+ if not path.exists():
+ return {}
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+
+ def _write_state(self, state: dict[str, Any]) -> None:
+ atomic_write_text(self._state_path(), json.dumps(state, indent=2, ensure_ascii=False))
+
+ def _emit_result(self, result: LeaderWatchResult) -> None:
+ if not (self.json_output or self.verbose):
+ return
+ if self.json_output:
+ print(
+ json.dumps(
+ {
+ "event": "leader_watch",
+ "team": self.team_name,
+ "leader": self.leader_name,
+ "injected": result.injected,
+ "reason": result.reason,
+ "redisEvent": result.redis_event,
+ },
+ ensure_ascii=False,
+ ),
+ flush=True,
+ )
+ return
+ if result.injected:
+ print(f"[leader-watch] injected reminder ({result.reason})", flush=True)
+
+
+def _task_ref(task) -> dict[str, str]:
+ return {
+ "id": task.id,
+ "owner": task.owner,
+ "updatedAt": task.updated_at,
+ "status": task.status.value,
+ }
diff --git a/clawteam/team/mailbox.py b/clawteam/team/mailbox.py
index 8c39e342..3da5fe1b 100644
--- a/clawteam/team/mailbox.py
+++ b/clawteam/team/mailbox.py
@@ -116,6 +116,19 @@ def send(
data = msg.model_dump_json(indent=2, by_alias=True, exclude_none=True).encode("utf-8")
self._transport.deliver(delivery_target, data)
self._log_event(msg)
+ try:
+ from clawteam.team.redis_wakeup import agent_channel, publish_wakeup, team_channel
+ payload = {
+ "from": from_agent,
+ "to": to,
+ "deliveryTarget": delivery_target,
+ "type": msg_type.value,
+ "requestId": msg.request_id,
+ }
+ publish_wakeup(self.team_name, agent_channel(self.team_name, delivery_target), "inbox", payload)
+ publish_wakeup(self.team_name, team_channel(self.team_name, "events"), "inbox", payload)
+ except Exception:
+ pass
try:
from clawteam.events.global_bus import get_event_bus
from clawteam.events.types import BeforeInboxSend
@@ -158,6 +171,19 @@ def broadcast(
).encode("utf-8")
self._transport.deliver(recipient, data)
self._log_event(msg)
+ try:
+ from clawteam.team.redis_wakeup import agent_channel, publish_wakeup, team_channel
+ payload = {
+ "from": from_agent,
+ "to": recipient,
+ "deliveryTarget": recipient,
+ "type": msg_type.value,
+ "requestId": msg.request_id,
+ }
+ publish_wakeup(self.team_name, agent_channel(self.team_name, recipient), "inbox", payload)
+ publish_wakeup(self.team_name, team_channel(self.team_name, "events"), "inbox", payload)
+ except Exception:
+ pass
messages.append(msg)
return messages
diff --git a/clawteam/team/redis_wakeup.py b/clawteam/team/redis_wakeup.py
new file mode 100644
index 00000000..db8409ec
--- /dev/null
+++ b/clawteam/team/redis_wakeup.py
@@ -0,0 +1,211 @@
+"""Optional Redis wakeup layer for team coordination."""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import socket
+import subprocess
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from clawteam.fileutil import atomic_write_text
+from clawteam.paths import ensure_within_root, validate_identifier
+from clawteam.team.models import get_data_dir
+
+
+@dataclass
+class RedisWakeup:
+ """Resolved Redis wakeup state."""
+
+ enabled: bool
+ url: str = ""
+ reason: str = ""
+ started: bool = False
+ pid: int = 0
+
+
+def team_channel(team_name: str, suffix: str) -> str:
+ validate_identifier(team_name, "team name")
+ return f"clawteam:{team_name}:{suffix}"
+
+
+def agent_channel(team_name: str, agent_name: str) -> str:
+ validate_identifier(agent_name, "agent name")
+ return team_channel(team_name, f"agent:{agent_name}")
+
+
+def resolve_wakeup(team_name: str, mode: str = "auto") -> RedisWakeup:
+ """Resolve and optionally start Redis for a watcher.
+
+ Modes:
+ - off: disabled
+ - redis://...: use the explicit URL
+ - auto: use env/state URL, or start local redis-server if available
+ """
+ mode = (mode or "auto").strip()
+ if mode == "off":
+ return RedisWakeup(False, reason="disabled")
+ if _redis_module() is None:
+ return RedisWakeup(False, reason="python redis package not installed")
+
+ if mode.startswith("redis://") or mode.startswith("rediss://"):
+ return _ping(mode)
+
+ url = os.environ.get("CLAWTEAM_REDIS_URL") or _read_state_url(team_name)
+ if url:
+ resolved = _ping(url)
+ if resolved.enabled:
+ return resolved
+
+ if mode != "auto":
+ return RedisWakeup(False, reason=f"unsupported redis mode: {mode}")
+
+ redis_server = shutil.which("redis-server")
+ if not redis_server:
+ return RedisWakeup(False, reason="redis-server not found")
+
+ return _start_local_redis(team_name, redis_server)
+
+
+def publish_wakeup(
+ team_name: str,
+ channel: str,
+ event_type: str,
+ payload: dict[str, Any] | None = None,
+) -> bool:
+ """Best-effort Redis publish. Never raises to callers."""
+ redis_mod = _redis_module()
+ if redis_mod is None:
+ return False
+ url = os.environ.get("CLAWTEAM_REDIS_URL") or _read_state_url(team_name)
+ if not url:
+ return False
+ try:
+ client = redis_mod.from_url(url)
+ message = {
+ "team": team_name,
+ "type": event_type,
+ "payload": payload or {},
+ "timestamp": time.time(),
+ }
+ client.publish(channel, json.dumps(message, ensure_ascii=False))
+ return True
+ except Exception:
+ return False
+
+
+def subscribe_client(url: str):
+ redis_mod = _redis_module()
+ if redis_mod is None:
+ return None
+ try:
+ return redis_mod.from_url(url)
+ except Exception:
+ return None
+
+
+def _redis_module():
+ try:
+ import redis
+ except ImportError:
+ return None
+ return redis
+
+
+def _team_dir(team_name: str) -> Path:
+ return ensure_within_root(get_data_dir() / "teams", validate_identifier(team_name, "team name"))
+
+
+def _state_path(team_name: str) -> Path:
+ return _team_dir(team_name) / "redis.json"
+
+
+def _read_state_url(team_name: str) -> str:
+ path = _state_path(team_name)
+ if not path.exists():
+ return ""
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ return str(data.get("url") or "")
+ except Exception:
+ return ""
+
+
+def _write_state(team_name: str, data: dict[str, Any]) -> None:
+ atomic_write_text(_state_path(team_name), json.dumps(data, indent=2, ensure_ascii=False))
+
+
+def _ping(url: str) -> RedisWakeup:
+ redis_mod = _redis_module()
+ if redis_mod is None:
+ return RedisWakeup(False, url=url, reason="python redis package not installed")
+ try:
+ client = redis_mod.from_url(url)
+ client.ping()
+ return RedisWakeup(True, url=url)
+ except Exception as exc:
+ return RedisWakeup(False, url=url, reason=str(exc))
+
+
+def _start_local_redis(team_name: str, redis_server: str) -> RedisWakeup:
+ port = _find_open_port()
+ url = f"redis://127.0.0.1:{port}/0"
+ redis_dir = _team_dir(team_name) / "redis"
+ redis_dir.mkdir(parents=True, exist_ok=True)
+ cmd = [
+ redis_server,
+ "--bind",
+ "127.0.0.1",
+ "--port",
+ str(port),
+ "--save",
+ "",
+ "--appendonly",
+ "no",
+ "--dir",
+ str(redis_dir),
+ ]
+ try:
+ proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ except Exception as exc:
+ return RedisWakeup(False, url=url, reason=str(exc))
+
+ deadline = time.monotonic() + 5.0
+ while time.monotonic() < deadline:
+ resolved = _ping(url)
+ if resolved.enabled:
+ _write_state(
+ team_name,
+ {
+ "url": url,
+ "pid": proc.pid,
+ "startedBy": "clawteam team watch",
+ "startedAt": time.time(),
+ },
+ )
+ return RedisWakeup(True, url=url, started=True, pid=proc.pid)
+ if proc.poll() is not None:
+ return RedisWakeup(False, url=url, reason="redis-server exited during startup")
+ time.sleep(0.1)
+ return RedisWakeup(False, url=url, reason="redis-server startup timed out")
+
+
+def _find_open_port(start: int = 6380, attempts: int = 100) -> int:
+ for port in range(start, start + attempts):
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.bind(("127.0.0.1", port))
+ return port
+ except OSError:
+ continue
+ raise RuntimeError("no open Redis port found")
diff --git a/dashboard/index.html b/dashboard/index.html
new file mode 100644
index 00000000..aadd4569
--- /dev/null
+++ b/dashboard/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ ClawTeam Dashboard
+
+
+
+
+
+
diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx
new file mode 100644
index 00000000..bc02082a
--- /dev/null
+++ b/dashboard/src/App.jsx
@@ -0,0 +1,1192 @@
+import { useEffect, useMemo, useState } from "react";
+import { dashboardApi, isDesktop, subscribeTeam } from "./api.js";
+import logoUrl from "./assets/logo.png";
+import terminalIcon from "./assets/client/terminal.png";
+import itermIcon from "./assets/client/iterm.png";
+import ghosttyIcon from "./assets/client/ghostty.png";
+import vscodeIcon from "./assets/client/vscode.png";
+import cursorIcon from "./assets/client/cursor.png";
+import claudeIcon from "./assets/agent/claude-code.png";
+import codexIcon from "./assets/agent/codex.png";
+import geminiIcon from "./assets/agent/gemini.png";
+import nanobotIcon from "./assets/agent/nanobot.png";
+
+function StatusGlyph({ status }) {
+ const common = {
+ width: 12,
+ height: 12,
+ viewBox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 2.4,
+ strokeLinecap: "round",
+ strokeLinejoin: "round",
+ "aria-hidden": true,
+ };
+ switch (status) {
+ case "pending":
+ return (
+
+ );
+ case "in_progress":
+ return (
+
+ );
+ case "completed":
+ return (
+
+ );
+ case "blocked":
+ return (
+
+ );
+ default:
+ return null;
+ }
+}
+
+const STATUSES = [
+ { key: "pending", label: "Pending" },
+ { key: "in_progress", label: "In progress" },
+ { key: "completed", label: "Completed" },
+ { key: "blocked", label: "Blocked" },
+];
+
+// The five clients clawteam knows how to spawn. Mirror of the wizard list in
+// clawteam/cli/commands.py::profile_wizard. New clients must be added here
+// AND in clawteam's `_normalize_client` / preset client_overrides.
+const PROFILE_CLIENTS = [
+ { id: "claude", label: "Claude Code", icon: claudeIcon, hint: "Anthropic CLI" },
+ { id: "codex", label: "Codex", icon: codexIcon, hint: "OpenAI Codex CLI" },
+ { id: "gemini", label: "Gemini", icon: geminiIcon, hint: "Google Gemini CLI" },
+ { id: "kimi", label: "Kimi", icon: null, hint: "Moonshot Kimi CLI" },
+ { id: "nanobot", label: "Nanobot", icon: nanobotIcon, hint: "Nanobot harness" },
+];
+
+const CLIENT_ICONS = {
+ terminal: terminalIcon,
+ iterm: itermIcon,
+ ghostty: ghosttyIcon,
+ vscode: vscodeIcon,
+ cursor: cursorIcon,
+};
+
+const NAV_ITEMS = ["board", "agents", "profiles"];
+
+const EMPTY_TEAM = {
+ team: null,
+ members: [],
+ tasks: { pending: [], in_progress: [], completed: [], blocked: [] },
+ taskSummary: { pending: 0, in_progress: 0, completed: 0, blocked: 0, total: 0 },
+ messages: [],
+ sessions: [],
+ cost: {},
+ conflicts: {},
+};
+
+function formatTime(value) {
+ if (!value) return "never";
+ const date = typeof value === "number" ? new Date(value * 1000) : new Date(value);
+ if (Number.isNaN(date.getTime())) return "unknown";
+ return new Intl.DateTimeFormat(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ month: "short",
+ day: "2-digit",
+ }).format(date);
+}
+
+function priorityRank(priority) {
+ return { urgent: 0, high: 1, medium: 2, low: 3 }[priority] ?? 4;
+}
+
+function sortTasks(tasks) {
+ return [...(tasks || [])].sort((left, right) => {
+ const byPriority = priorityRank(left.priority) - priorityRank(right.priority);
+ if (byPriority !== 0) return byPriority;
+ return String(right.updatedAt || "").localeCompare(String(left.updatedAt || ""));
+ });
+}
+
+export function App() {
+ const [view, setView] = useState("board");
+ const [teams, setTeams] = useState([]);
+ const [selectedTeam, setSelectedTeam] = useState("");
+ const [teamData, setTeamData] = useState(EMPTY_TEAM);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [runtime, setRuntime] = useState(null);
+ const [runtimeLogs, setRuntimeLogs] = useState([]);
+ const [profiles, setProfiles] = useState({});
+ const [presets, setPresets] = useState({});
+ const [wizardClient, setWizardClient] = useState("");
+ const [wizardPreset, setWizardPreset] = useState("");
+ const [wizardName, setWizardName] = useState("");
+ const [wizardBusy, setWizardBusy] = useState(false);
+ const [wizardNotice, setWizardNotice] = useState("");
+ const [launchTargets, setLaunchTargets] = useState([]);
+ const [profileDraft, setProfileDraft] = useState(defaultProfileDraft());
+ const [profileName, setProfileName] = useState("");
+ const [profileNotice, setProfileNotice] = useState("");
+ const [sessionNotice, setSessionNotice] = useState("");
+ const [taskDraft, setTaskDraft] = useState({ subject: "", owner: "", description: "" });
+ const [taskModalOpen, setTaskModalOpen] = useState(false);
+ const [taskNotice, setTaskNotice] = useState("");
+ const [settingsOpen, setSettingsOpen] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+ async function loadInitial() {
+ try {
+ const [overview, status, profileMap, targets, presetMap] = await Promise.all([
+ dashboardApi.listTeams(),
+ dashboardApi.getRuntimeStatus().catch(() => null),
+ dashboardApi.listProfiles().catch(() => ({})),
+ dashboardApi.listLaunchTargets().catch(() => []),
+ dashboardApi.listPresets().catch(() => ({})),
+ ]);
+ if (!mounted) return;
+ setTeams(overview);
+ setRuntime(status);
+ setProfiles(profileMap);
+ setLaunchTargets(targets);
+ setPresets(presetMap);
+ if (overview.length > 0) {
+ setSelectedTeam(overview[0].name);
+ } else {
+ setLoading(false);
+ }
+ } catch (err) {
+ if (!mounted) return;
+ setError(err.message);
+ setLoading(false);
+ }
+ }
+ loadInitial();
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!selectedTeam) return undefined;
+ setLoading(true);
+ setError("");
+ dashboardApi
+ .getTeam(selectedTeam)
+ .then((data) => {
+ setTeamData({ ...EMPTY_TEAM, ...data });
+ setLoading(false);
+ })
+ .catch((err) => {
+ setError(err.message);
+ setTeamData(EMPTY_TEAM);
+ setLoading(false);
+ });
+
+ return subscribeTeam(
+ selectedTeam,
+ (data) => {
+ if (!data.error) {
+ setTeamData({ ...EMPTY_TEAM, ...data });
+ setLoading(false);
+ }
+ },
+ (err) => setError(err.message),
+ );
+ }, [selectedTeam]);
+
+ useEffect(() => {
+ if (!taskModalOpen && !settingsOpen) return undefined;
+ const onKey = (event) => {
+ if (event.key !== "Escape") return;
+ if (taskModalOpen) setTaskModalOpen(false);
+ else if (settingsOpen) setSettingsOpen(false);
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [taskModalOpen, settingsOpen]);
+
+ const sessionsByName = useMemo(() => {
+ const map = new Map();
+ for (const session of teamData.sessions || []) map.set(session.agentName, session);
+ return map;
+ }, [teamData.sessions]);
+
+ const metrics = useMemo(() => {
+ const summary = teamData.taskSummary || {};
+ const sessions = teamData.sessions || [];
+ return [
+ { label: "Tasks", value: summary.total || 0 },
+ { label: "Agents", value: teamData.members?.length || 0 },
+ { label: "Live sessions", value: sessions.filter((item) => item.alive === true).length },
+ { label: "Conflicts", value: teamData.conflicts?.totalOverlaps || 0 },
+ ];
+ }, [teamData]);
+
+ async function refreshRuntime() {
+ const status = await dashboardApi.getRuntimeStatus();
+ setRuntime(status);
+ }
+
+ async function runRuntimeAction(action) {
+ setRuntimeLogs([`Starting ${action}...`]);
+ try {
+ const result =
+ action === "install"
+ ? await dashboardApi.installClawTeam()
+ : await dashboardApi.upgradeClawTeam();
+ setRuntimeLogs(result.logs || []);
+ await refreshRuntime();
+ } catch (err) {
+ setRuntimeLogs((logs) => [...logs, `Error: ${err.message}`]);
+ }
+ }
+
+ async function saveProfile() {
+ if (!profileName.trim()) return;
+ const result = await dashboardApi.saveProfile(profileName.trim(), normalizeProfile(profileDraft));
+ setProfiles(result.profiles || (await dashboardApi.listProfiles()));
+ }
+
+ async function deleteProfile(name) {
+ const result = await dashboardApi.removeProfile(name);
+ setProfiles(result.profiles || (await dashboardApi.listProfiles()));
+ if (profileName === name) {
+ setProfileName("");
+ setProfileDraft(defaultProfileDraft());
+ }
+ }
+
+ async function testProfile(name) {
+ setProfileNotice("");
+ try {
+ const result = await dashboardApi.testProfile(name);
+ const code = result?.result?.returncode;
+ setProfileNotice(code === 0 ? `Profile '${name}' passed.` : `Profile '${name}' returned ${code}.`);
+ } catch (err) {
+ setProfileNotice(err.message);
+ }
+ }
+
+ function editProfile(name, profile) {
+ setProfileName(name);
+ setProfileDraft({
+ description: profile.description || "",
+ agent: profile.agent || "",
+ command: (profile.command || []).join(" "),
+ model: profile.model || "",
+ base_url: profile.base_url || "",
+ base_url_env: profile.base_url_env || "",
+ api_key_env: profile.api_key_env || "",
+ api_key_target_env: profile.api_key_target_env || "",
+ env: mapToLines(profile.env),
+ env_map: mapToLines(profile.env_map),
+ args: (profile.args || []).join("\n"),
+ });
+ }
+
+ function pickWizardClient(clientId) {
+ setWizardNotice("");
+ setWizardClient((prev) => {
+ if (prev === clientId) return prev;
+ setWizardPreset("");
+ setWizardName("");
+ return clientId;
+ });
+ }
+
+ function pickWizardPreset(presetName) {
+ setWizardNotice("");
+ setWizardPreset(presetName);
+ setWizardName(`${wizardClient}-${presetName}`);
+ }
+
+ async function generateFromWizard() {
+ if (!wizardClient || !wizardPreset) return;
+ setWizardBusy(true);
+ setWizardNotice("");
+ try {
+ const result = await dashboardApi.generateProfileFromPreset({
+ preset: wizardPreset,
+ client: wizardClient,
+ name: wizardName.trim() || undefined,
+ });
+ const next = result.profiles || (await dashboardApi.listProfiles());
+ setProfiles(next);
+ const generated = result.result?.profile || wizardName.trim() || `${wizardClient}-${wizardPreset}`;
+ setWizardNotice(`Saved profile '${generated}'.`);
+ const fresh = next[generated];
+ if (fresh) editProfile(generated, fresh);
+ setWizardClient("");
+ setWizardPreset("");
+ setWizardName("");
+ } catch (err) {
+ setWizardNotice(err.message);
+ } finally {
+ setWizardBusy(false);
+ }
+ }
+
+ async function createTask() {
+ if (!selectedTeam || !taskDraft.subject.trim()) {
+ setTaskNotice("Subject is required.");
+ return;
+ }
+ try {
+ await dashboardApi.createTask(selectedTeam, taskDraft);
+ setTaskDraft({ subject: "", owner: "", description: "" });
+ setTaskNotice("");
+ setTaskModalOpen(false);
+ const next = await dashboardApi.getTeam(selectedTeam);
+ setTeamData({ ...EMPTY_TEAM, ...next });
+ } catch (err) {
+ setTaskNotice(err.message);
+ }
+ }
+
+ async function openSession(session, target) {
+ setSessionNotice("");
+ if (session?.alive !== true && ["terminal", "iterm", "ghostty"].includes(target)) {
+ setSessionNotice(`Session '${session?.agentName || "agent"}' is offline.`);
+ return;
+ }
+ try {
+ const result = await dashboardApi.openSession({
+ team: selectedTeam,
+ session,
+ target,
+ });
+ setSessionNotice(result.message || "Opened session.");
+ } catch (err) {
+ setSessionNotice(err.message);
+ }
+ }
+
+ function openTeam(teamName) {
+ setSelectedTeam(teamName);
+ setView("board");
+ }
+
+ return (
+
+
+
+
+
+
+ {error ? {error}
: null}
+ {loading && view === "board" ? Loading board data...
: null}
+
+ {view === "board" && (
+
+ )}
+ {view === "agents" && (
+
+ )}
+ {view === "profiles" && (
+
+ )}
+
+
+ {settingsOpen ? (
+
setSettingsOpen(false)}>
+
+
+ ) : null}
+
+ {taskModalOpen ? (
+
setTaskModalOpen(false)}
+ onSubmit={createTask}
+ />
+ ) : null}
+
+ );
+}
+
+function viewEyebrow(view) {
+ switch (view) {
+ case "board":
+ return "Kanban command center";
+ case "agents":
+ return "Agent sessions";
+ case "profiles":
+ return "Agent profiles";
+ case "settings":
+ return "Runtime & launchers";
+ default:
+ return "";
+ }
+}
+
+function topbarTitle(view, teamData, selectedTeam) {
+ if (view === "board") {
+ return teamData.team?.name || selectedTeam || "No team selected";
+ }
+ return view.charAt(0).toUpperCase() + view.slice(1);
+}
+
+function displayVersion(value) {
+ const text = String(value || "").trim();
+ if (!text || text === "unknown") return "unknown";
+ const twoPart = text.match(/^([1-9]\d*)\.(\d+)$/);
+ if (twoPart) return `0.${twoPart[1]}.${twoPart[2]}`;
+ return text;
+}
+
+function BoardView({ data, metrics, sessionsByName }) {
+ return (
+
+
+ {metrics.map((metric) => (
+
+ {metric.label}
+ {metric.value}
+
+ ))}
+
+
+
+ {STATUSES.map((status) => (
+
+
+
+
+
+
{status.label}
+ {data.taskSummary?.[status.key] || 0}
+
+
+ {sortTasks(data.tasks?.[status.key]).map((task) => {
+ const session = sessionsByName.get(task.owner);
+ return ;
+ })}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function TaskCard({ task, session }) {
+ return (
+
+
+ {task.priority || "medium"}
+ {task.id}
+
+ {task.subject}
+ {task.description ? {task.description}
: null}
+
+ {task.owner || "unassigned"}
+
+ {session?.alive ? "live" : "idle"}
+
+
+ {task.blockedBy?.length ? blocked by {task.blockedBy.join(", ")}
: null}
+
+ );
+}
+
+function AgentsView({ data, launchTargets, onOpenSession, notice }) {
+ return (
+
+
+
+
Agent sessions
+ {data.sessions?.length || 0} registered
+
+
+ {(data.sessions || []).length === 0 ? (
+
No registered sessions.
+ ) : (
+ (data.sessions || []).map((session) => {
+ const sessionAlive = session.alive === true;
+ const canAttach = (target) =>
+ sessionAlive || !["terminal", "iterm", "ghostty"].includes(target.id);
+ const sessionRef = session.sessionId
+ ? `session ${session.sessionId}`
+ : session.target || session.pid || "";
+ const sessionMeta = session.sessionId
+ ? [
+ session.sessionClient,
+ session.sessionConfidence,
+ session.sessionSource,
+ ].filter(Boolean).join(" · ")
+ : "";
+ return (
+
+
+ {session.agentName}
+ {session.backend} {sessionRef}
+ {sessionMeta ? {sessionMeta} : null}
+
+
+ {sessionAlive ? "running" : "offline"}
+
+
+ {launchTargets.map((target) => (
+
+ ))}
+
+
+ );
+ })
+ )}
+
+ {notice ? {notice}
: null}
+
+
+ );
+}
+
+function ProfilesView({
+ profiles,
+ profileName,
+ setProfileName,
+ profileDraft,
+ setProfileDraft,
+ editProfile,
+ saveProfile,
+ deleteProfile,
+ testProfile,
+ profileNotice,
+ presets,
+ wizardClient,
+ wizardPreset,
+ wizardName,
+ setWizardName,
+ pickWizardClient,
+ pickWizardPreset,
+ wizardNotice,
+ wizardBusy,
+ generateFromWizard,
+}) {
+ const presetEntries = useMemo(() => Object.entries(presets || {}), [presets]);
+
+ const compatiblePresets = useMemo(() => {
+ if (!wizardClient) return [];
+ return presetEntries
+ .filter(([, item]) => Boolean(item?.preset?.client_overrides?.[wizardClient]))
+ .sort(([a], [b]) => a.localeCompare(b));
+ }, [presetEntries, wizardClient]);
+
+ return (
+
+
+
+
New profile
+ preset → client
+
+
+
+
+ 1
+ Choose a client
+
+
+ {PROFILE_CLIENTS.map((client) => (
+
+ ))}
+
+
+
+
+
+ 2
+ Choose a provider
+ {wizardClient ? (
+
+ {compatiblePresets.length} support {wizardClient}
+
+ ) : null}
+
+ {wizardClient ? (
+ compatiblePresets.length === 0 ? (
+
No presets define a {wizardClient} override. Use Advanced below.
+ ) : (
+
+ {compatiblePresets.map(([name, item]) => {
+ const preset = item.preset || {};
+ const selected = wizardPreset === name;
+ return (
+
+ );
+ })}
+
+ )
+ ) : (
+
Pick a client first.
+ )}
+
+
+
+
+ 3
+ Profile name
+
+
+ setWizardName(event.target.value)}
+ placeholder={wizardClient && wizardPreset ? `${wizardClient}-${wizardPreset}` : "name"}
+ />
+
+
+ {wizardNotice ?
{wizardNotice}
: null}
+
+
+
+
Saved profiles
+ {Object.keys(profiles).length} configured
+
+ {Object.keys(profiles).length === 0 ? (
+
+ No saved profiles yet — pick a client + provider above, or use Advanced for a custom command.
+
+ ) : null}
+ {Object.entries(profiles).map(([name, profile]) => (
+
+
+ {name}
+
+ {profile.agent || profile.command?.join(" ") || "custom command"}
+ {profile.model ? ` · ${profile.model}` : ""}
+
+
+
+
+
+
+ ))}
+ {profileNotice ? {profileNotice}
: null}
+ {!isDesktop ? Profile editing is enabled in the desktop app.
: null}
+
+
+
+
+ );
+}
+
+function SettingsView({ runtime, launchTargets, logs, refreshRuntime, runRuntimeAction }) {
+ const upgradeAvailable = runtime?.upgrade_available;
+ const installed = runtime?.installed;
+ const currentVersion = displayVersion(runtime?.current_version || runtime?.latest_version);
+ return (
+
+
+
+
+
ClawTeam runtime
+
+ {installed ? `v${currentVersion}` : "Not installed"}
+
+ {installed ? (
+
+ {upgradeAvailable ? `Update available: v${displayVersion(runtime?.latest_version)}` : "Runtime is current."}
+
+ ) : (
+
Install ClawTeam to start serving the board.
+ )}
+
+
+
+ {!installed ? (
+
+ ) : (
+
+ )}
+
+
+
+ {logs.length ? (
+
+ Install log
+ {logs.join("\n")}
+
+ ) : null}
+
+ {!isDesktop ? (
+ Install and upgrade run only in the desktop app.
+ ) : null}
+
+
+
+
+
Launch with
+ open agent sessions
+
+
+ {launchTargets.map((target) => (
+
+ ))}
+ {launchTargets.length === 0 ? (
+
No launchers detected.
+ ) : null}
+
+
+
+ );
+}
+
+function ClientTile({ target }) {
+ const icon = CLIENT_ICONS[target.id];
+ const initials = target.label.slice(0, 2).toUpperCase();
+ return (
+
+
+ {icon ?

:
{initials}}
+
+
+ {target.label}
+ {target.description}
+
+
+ {target.available ? "ready" : "missing"}
+
+
+ );
+}
+
+function SettingsFabTrigger({ runtime, open, onToggle }) {
+ const status = !runtime?.installed
+ ? "off"
+ : runtime?.upgrade_available
+ ? "warn"
+ : "ok";
+ const label = runtime?.installed
+ ? `v${displayVersion(runtime?.current_version)}`
+ : "not installed";
+ return (
+
+ );
+}
+
+function SettingsPopover({ onClose, children }) {
+ useEffect(() => {
+ const onDown = (event) => {
+ const popover = document.getElementById("settings-popover");
+ const trigger = document.getElementById("settings-trigger");
+ if (popover && popover.contains(event.target)) return;
+ if (trigger && trigger.contains(event.target)) return;
+ onClose();
+ };
+ window.addEventListener("mousedown", onDown);
+ return () => window.removeEventListener("mousedown", onDown);
+ }, [onClose]);
+
+ return (
+
+
+ Runtime & launchers
+
+
+
{children}
+
+ );
+}
+
+function NewTaskModal({ team, draft, setDraft, notice, onCancel, onSubmit }) {
+ return (
+ {
+ if (event.target === event.currentTarget) onCancel();
+ }}
+ >
+
+
+
+
{team || "no team"}
+
New task
+
+
+
+
+
+
+
+ {notice ?
{notice}
: null}
+
+
+
+
+ );
+}
+
+function defaultProfileDraft() {
+ return {
+ description: "",
+ agent: "",
+ command: "",
+ model: "",
+ base_url: "",
+ base_url_env: "",
+ api_key_env: "",
+ api_key_target_env: "",
+ env: "",
+ env_map: "",
+ args: "",
+ };
+}
+
+function linesToMap(raw) {
+ const result = {};
+ for (const line of raw.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || !trimmed.includes("=")) continue;
+ const [key, ...rest] = trimmed.split("=");
+ result[key] = rest.join("=");
+ }
+ return result;
+}
+
+function mapToLines(map = {}) {
+ return Object.entries(map).map(([key, value]) => `${key}=${value}`).join("\n");
+}
+
+function normalizeProfile(draft) {
+ return {
+ description: draft.description,
+ agent: draft.agent,
+ command: splitCommand(draft.command),
+ model: draft.model,
+ base_url: draft.base_url,
+ base_url_env: draft.base_url_env,
+ api_key_env: draft.api_key_env,
+ api_key_target_env: draft.api_key_target_env,
+ env: linesToMap(draft.env),
+ env_map: linesToMap(draft.env_map),
+ args: draft.args.split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
+ };
+}
+
+function splitCommand(command) {
+ return command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((part) => part.replace(/^["']|["']$/g, "")) || [];
+}
diff --git a/dashboard/src/api.js b/dashboard/src/api.js
new file mode 100644
index 00000000..74f2ec8e
--- /dev/null
+++ b/dashboard/src/api.js
@@ -0,0 +1,121 @@
+const DESKTOP_API = window.clawteamDesktop || null;
+
+async function requestJson(path, options = {}) {
+ const response = await fetch(path, {
+ headers: {
+ "Content-Type": "application/json",
+ ...(options.headers || {}),
+ },
+ ...options,
+ });
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(body.error || body.message || `Request failed: ${response.status}`);
+ }
+ return body;
+}
+
+export const isDesktop = Boolean(DESKTOP_API);
+
+export const dashboardApi = {
+ listTeams() {
+ return requestJson("/api/overview");
+ },
+ getTeam(team) {
+ return requestJson(`/api/team/${encodeURIComponent(team)}`);
+ },
+ createTask(team, payload) {
+ return requestJson(`/api/team/${encodeURIComponent(team)}/task`, {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+ },
+ getRuntimeStatus() {
+ if (DESKTOP_API) return DESKTOP_API.getRuntimeStatus();
+ return requestJson("/api/runtime/status");
+ },
+ installClawTeam() {
+ if (!DESKTOP_API) {
+ return Promise.reject(new Error("Install is available in the desktop app."));
+ }
+ return DESKTOP_API.installClawTeam();
+ },
+ upgradeClawTeam() {
+ if (!DESKTOP_API) {
+ return Promise.reject(new Error("Upgrade is available in the desktop app."));
+ }
+ return DESKTOP_API.upgradeClawTeam();
+ },
+ listProfiles() {
+ if (!DESKTOP_API) return Promise.resolve({});
+ return DESKTOP_API.listProfiles();
+ },
+ saveProfile(name, profile) {
+ if (!DESKTOP_API) {
+ return Promise.reject(new Error("Profile editing is available in the desktop app."));
+ }
+ return DESKTOP_API.saveProfile(name, profile);
+ },
+ removeProfile(name) {
+ if (!DESKTOP_API) {
+ return Promise.reject(new Error("Profile editing is available in the desktop app."));
+ }
+ return DESKTOP_API.removeProfile(name);
+ },
+ testProfile(name) {
+ if (!DESKTOP_API) {
+ return Promise.reject(new Error("Profile testing is available in the desktop app."));
+ }
+ return DESKTOP_API.testProfile(name);
+ },
+ listPresets() {
+ if (!DESKTOP_API) return Promise.resolve({});
+ return DESKTOP_API.listPresets();
+ },
+ generateProfileFromPreset(payload) {
+ if (!DESKTOP_API) {
+ return Promise.reject(new Error("Profile generation is available in the desktop app."));
+ }
+ return DESKTOP_API.generateProfileFromPreset(payload);
+ },
+ listLaunchTargets() {
+ if (!DESKTOP_API) return Promise.resolve([]);
+ return DESKTOP_API.listLaunchTargets();
+ },
+ openSession(payload) {
+ if (!DESKTOP_API) {
+ return Promise.reject(new Error("Session opening is available in the desktop app."));
+ }
+ return DESKTOP_API.openSession(payload);
+ },
+ getBoardStatus() {
+ if (!DESKTOP_API) return Promise.resolve({ status: "running", port: 0, url: "" });
+ return DESKTOP_API.getBoardStatus();
+ },
+ ensureBoard() {
+ if (!DESKTOP_API) return Promise.resolve({ status: "running", port: 0, url: "" });
+ return DESKTOP_API.ensureBoard();
+ },
+ restartBoard() {
+ if (!DESKTOP_API) {
+ return Promise.reject(new Error("Board restart is available in the desktop app."));
+ }
+ return DESKTOP_API.restartBoard();
+ },
+};
+
+export function subscribeTeam(team, onData, onError) {
+ if (!team) return () => {};
+ const source = new EventSource(`/api/events/${encodeURIComponent(team)}`);
+ source.onmessage = (event) => {
+ try {
+ onData(JSON.parse(event.data));
+ } catch (error) {
+ onError?.(error);
+ }
+ };
+ source.onerror = () => {
+ onError?.(new Error("Live connection interrupted."));
+ };
+ return () => source.close();
+}
diff --git a/dashboard/src/assets/agent/claude-code.png b/dashboard/src/assets/agent/claude-code.png
new file mode 100644
index 00000000..1f3693cf
Binary files /dev/null and b/dashboard/src/assets/agent/claude-code.png differ
diff --git a/dashboard/src/assets/agent/codex.png b/dashboard/src/assets/agent/codex.png
new file mode 100644
index 00000000..11155e4d
Binary files /dev/null and b/dashboard/src/assets/agent/codex.png differ
diff --git a/dashboard/src/assets/agent/gemini.png b/dashboard/src/assets/agent/gemini.png
new file mode 100644
index 00000000..2f26055b
Binary files /dev/null and b/dashboard/src/assets/agent/gemini.png differ
diff --git a/dashboard/src/assets/agent/nanobot.png b/dashboard/src/assets/agent/nanobot.png
new file mode 100644
index 00000000..ce92c467
Binary files /dev/null and b/dashboard/src/assets/agent/nanobot.png differ
diff --git a/dashboard/src/assets/client/cursor.png b/dashboard/src/assets/client/cursor.png
new file mode 100644
index 00000000..4ce77b0b
Binary files /dev/null and b/dashboard/src/assets/client/cursor.png differ
diff --git a/dashboard/src/assets/client/ghostty.png b/dashboard/src/assets/client/ghostty.png
new file mode 100644
index 00000000..936e3f98
Binary files /dev/null and b/dashboard/src/assets/client/ghostty.png differ
diff --git a/dashboard/src/assets/client/iterm.png b/dashboard/src/assets/client/iterm.png
new file mode 100644
index 00000000..19cfd018
Binary files /dev/null and b/dashboard/src/assets/client/iterm.png differ
diff --git a/dashboard/src/assets/client/terminal.png b/dashboard/src/assets/client/terminal.png
new file mode 100644
index 00000000..2034da30
Binary files /dev/null and b/dashboard/src/assets/client/terminal.png differ
diff --git a/dashboard/src/assets/client/vscode.png b/dashboard/src/assets/client/vscode.png
new file mode 100644
index 00000000..b5d5a6e6
Binary files /dev/null and b/dashboard/src/assets/client/vscode.png differ
diff --git a/dashboard/src/assets/logo.png b/dashboard/src/assets/logo.png
new file mode 100644
index 00000000..9d15b721
Binary files /dev/null and b/dashboard/src/assets/logo.png differ
diff --git a/dashboard/src/main.jsx b/dashboard/src/main.jsx
new file mode 100644
index 00000000..aeca6f6c
--- /dev/null
+++ b/dashboard/src/main.jsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { App } from "./App.jsx";
+import "./styles.css";
+
+ReactDOM.createRoot(document.getElementById("root")).render(
+
+
+ ,
+);
diff --git a/dashboard/src/styles.css b/dashboard/src/styles.css
new file mode 100644
index 00000000..b1d0288d
--- /dev/null
+++ b/dashboard/src/styles.css
@@ -0,0 +1,1499 @@
+@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;1,500&family=Inter+Tight:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap");
+
+:root {
+ color-scheme: light;
+
+ /* Monolith palette — off-white surface, graphite ink, single accent. */
+ --bg: #f6f4ef;
+ --bg-deep: #ecebe5;
+ --surface: #ffffff;
+ --ink: #0d0d0d;
+ --ink-soft: #3a3a38;
+ --muted: #8a8880;
+ --rule: rgba(13, 13, 13, 0.08);
+ --rule-strong: rgba(13, 13, 13, 0.18);
+
+ /* Inverse "monolith" surface — used sparingly for emphasis. */
+ --card: #0e0e10;
+ --card-ink: #f3efe6;
+ --card-muted: rgba(243, 239, 230, 0.55);
+
+ /* Single electric accent + restrained semantic colors. */
+ --accent: #ff4d1f;
+ --accent-soft: rgba(255, 77, 31, 0.12);
+ --green: #2ed158;
+ --danger: #c23a20;
+ --pending: #b8782a;
+
+ --radius: 6px;
+ --radius-sm: 4px;
+ --hairline: 0.5px solid var(--rule);
+
+ --font: "Inter Tight", "Helvetica Neue", Arial, sans-serif;
+ --mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
+ --serif: "Cormorant Garamond", Georgia, serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ overflow: hidden;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--font);
+ -webkit-font-smoothing: antialiased;
+ line-height: 1.4;
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+ color: var(--ink);
+}
+
+button {
+ min-height: 32px;
+ padding: 0 12px;
+ border: 0.5px solid var(--rule-strong);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ font-family: var(--mono);
+ font-size: 12px;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
+}
+
+button:hover:not(:disabled) {
+ border-color: var(--ink);
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.4;
+}
+
+input,
+textarea {
+ width: 100%;
+ border: 0.5px solid var(--rule-strong);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ padding: 9px 11px;
+ outline: none;
+ transition: border-color 120ms ease, box-shadow 120ms ease;
+}
+
+textarea {
+ resize: vertical;
+ font-family: var(--mono);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+input::placeholder,
+textarea::placeholder {
+ color: var(--muted);
+ opacity: 1;
+}
+
+input:focus,
+textarea:focus {
+ border-color: var(--ink);
+ box-shadow: 0 0 0 2px var(--accent-soft);
+}
+
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: rgba(13, 13, 13, 0.18);
+ border-radius: 3px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: rgba(13, 13, 13, 0.32);
+}
+
+/* ── Shell ───────────────────────────────────────── */
+
+.app-shell {
+ display: grid;
+ grid-template-columns: 256px 1fr;
+ width: 100%;
+ height: 100%;
+}
+
+.sidebar {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 28px;
+ padding: 22px 20px;
+ border-right: 0.5px solid var(--rule);
+ background: var(--bg-deep);
+}
+
+.brand-block {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.brand-mark {
+ position: relative;
+ display: inline-flex;
+ width: 36px;
+ height: 36px;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ border-radius: 50%;
+ background: #131826;
+}
+
+.brand-mark img {
+ width: 112%;
+ height: 112%;
+ object-fit: cover;
+ display: block;
+}
+
+.brand-title {
+ font-size: 17px;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ color: var(--ink);
+}
+
+.brand-subtitle,
+.eyebrow {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+}
+
+.nav-stack,
+.team-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.nav-item,
+.team-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ min-height: 30px;
+ padding: 6px 10px;
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ text-align: left;
+ font-family: var(--font);
+ font-size: 13px;
+ color: var(--ink-soft);
+ transition: background 120ms ease, color 120ms ease;
+}
+
+.nav-item span {
+ text-transform: capitalize;
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.06em;
+}
+
+.nav-item:hover:not(.active),
+.team-row:hover:not(.selected) {
+ background: rgba(13, 13, 13, 0.04);
+ border-color: transparent;
+}
+
+.nav-item.active,
+.team-row.selected {
+ background: var(--card);
+ color: var(--card-ink);
+}
+
+.team-row {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ padding: 8px 10px;
+ min-height: 46px;
+}
+
+.team-row-main {
+ overflow: hidden;
+ max-width: 100%;
+ font-weight: 500;
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.team-row-meta,
+.muted {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.02em;
+}
+
+.team-row.selected .team-row-meta {
+ color: var(--card-muted);
+}
+
+.status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--green);
+}
+
+.status-dot.warn {
+ background: var(--accent);
+}
+
+.status-dot.off {
+ background: var(--rule-strong);
+}
+
+/* ── Main column ─────────────────────────────────── */
+
+.main-panel {
+ overflow: auto;
+ min-width: 0;
+ padding: 28px 32px 36px;
+ background: var(--bg);
+}
+
+.topbar {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 22px;
+ padding-bottom: 18px;
+ border-bottom: 0.5px solid var(--rule);
+}
+
+.topbar > div:first-child {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.topbar h1 {
+ margin: 0;
+ font-size: 26px;
+ font-weight: 600;
+ line-height: 1.05;
+ letter-spacing: -0.02em;
+ color: var(--ink);
+}
+
+.topbar-actions,
+.action-row,
+.row-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.primary-action {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ padding: 0 14px;
+ border: 0.5px solid var(--ink);
+ border-radius: 999px;
+ background: var(--card);
+ color: var(--card-ink);
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.primary-action:hover:not(:disabled) {
+ background: var(--ink);
+ border-color: var(--ink);
+ color: var(--card-ink);
+}
+
+.primary-action:disabled {
+ background: var(--bg-deep);
+ border-color: var(--rule);
+ color: var(--muted);
+ opacity: 1;
+}
+
+.plus-glyph {
+ display: inline-flex;
+ width: 14px;
+ height: 14px;
+ align-items: center;
+ justify-content: center;
+ margin-right: 2px;
+ font-size: 14px;
+ line-height: 1;
+ font-family: var(--font);
+ font-weight: 400;
+}
+
+.icon-button {
+ width: 28px;
+ height: 28px;
+ min-height: 28px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-size: 18px;
+ line-height: 1;
+}
+
+.icon-button:hover {
+ color: var(--ink);
+ border-color: transparent;
+}
+
+.notice {
+ margin-bottom: 12px;
+ border: 0.5px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--surface);
+ padding: 11px 14px;
+ color: var(--ink-soft);
+ font-size: 13px;
+}
+
+.notice.danger {
+ border-color: rgba(194, 58, 32, 0.35);
+ color: var(--danger);
+}
+
+/* ── Board layout ────────────────────────────────── */
+
+.board-layout {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.metrics-band {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(130px, 1fr));
+ gap: 10px;
+}
+
+.metric {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-height: 84px;
+ border: 0.5px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--surface);
+ padding: 14px 16px;
+}
+
+.metric span {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+}
+
+.metric strong {
+ display: block;
+ margin-top: auto;
+ font-size: 30px;
+ font-weight: 500;
+ letter-spacing: -0.04em;
+ color: var(--ink);
+ font-feature-settings: "tnum" 1;
+}
+
+/* The first metric carries the signature monolith inverse to anchor the page. */
+.metrics-band .metric:first-child {
+ background: var(--card);
+ border-color: transparent;
+ color: var(--card-ink);
+}
+.metrics-band .metric:first-child span {
+ color: var(--card-muted);
+}
+.metrics-band .metric:first-child strong {
+ color: var(--card-ink);
+}
+
+.kanban {
+ display: grid;
+ height: clamp(420px, calc(100vh - 300px), 780px);
+ min-height: 0;
+ grid-template-columns: repeat(4, minmax(180px, 1fr));
+ gap: 10px;
+}
+
+.kanban-column {
+ display: flex;
+ overflow: hidden;
+ min-width: 0;
+ min-height: 0;
+ flex-direction: column;
+ border: 0.5px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--surface);
+}
+
+.column-header {
+ display: grid;
+ align-items: center;
+ grid-template-columns: 24px 1fr auto;
+ gap: 10px;
+ flex: 0 0 auto;
+ min-height: 46px;
+ padding: 10px 12px;
+ border-bottom: 0.5px solid var(--rule);
+}
+
+.column-header h2 {
+ overflow: hidden;
+ margin: 0;
+ font-size: 13px;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--ink);
+}
+
+.column-header > span:last-child {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 11px;
+ font-feature-settings: "tnum" 1;
+}
+
+.column-code {
+ display: grid;
+ width: 22px;
+ height: 22px;
+ place-items: center;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--ink-soft);
+}
+
+.column-code.status-pending {
+ color: var(--muted);
+}
+
+.column-code.status-in_progress {
+ color: var(--accent);
+}
+
+.column-code.status-completed {
+ color: var(--green);
+}
+
+.column-code.status-blocked {
+ color: var(--danger);
+}
+
+.task-stack {
+ display: flex;
+ overflow-y: auto;
+ overflow-x: hidden;
+ flex-direction: column;
+ flex: 1 1 auto;
+ gap: 8px;
+ min-height: 0;
+ padding: 10px;
+ overscroll-behavior: contain;
+}
+
+.task-card {
+ border: 0.5px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--surface);
+ padding: 12px;
+ transition: border-color 120ms ease;
+}
+
+.task-card:hover {
+ border-color: var(--rule-strong);
+}
+
+.task-card h3 {
+ margin: 8px 0 6px;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.25;
+ letter-spacing: -0.01em;
+ color: var(--ink);
+}
+
+.task-card p,
+.message-row p {
+ overflow: hidden;
+ display: -webkit-box;
+ margin: 0;
+ color: var(--ink-soft);
+ font-size: 12px;
+ line-height: 1.45;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+}
+
+.task-meta,
+.task-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.task-footer {
+ margin-top: 10px;
+}
+
+.live-pill {
+ display: inline-flex;
+ min-height: 18px;
+ align-items: center;
+ gap: 5px;
+ border: 0.5px solid var(--rule-strong);
+ border-radius: 999px;
+ padding: 0 8px;
+ color: var(--ink-soft);
+ background: transparent;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.live-pill::before {
+ content: "";
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--green);
+}
+
+.live-pill.off {
+ color: var(--muted);
+}
+
+.live-pill.off::before {
+ background: var(--rule-strong);
+}
+
+.blocked-by {
+ margin-top: 8px;
+ color: var(--danger);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+/* ── Side / table panels ─────────────────────────── */
+
+.inspector,
+.table-panel,
+.editor-panel {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 14px;
+ border: 0.5px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--surface);
+ padding: 16px;
+}
+
+.panel-section {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.panel-section + .panel-section {
+ padding-top: 14px;
+ border-top: 0.5px solid var(--rule);
+}
+
+/* Wide screens: split inspector into two columns side-by-side
+ (only when it has more than one panel-section — `.single` opts out). */
+@media (min-width: 1100px) {
+ .inspector:not(.single) {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 24px;
+ align-items: start;
+ }
+
+ .inspector:not(.single) .panel-section + .panel-section {
+ padding-top: 0;
+ padding-left: 24px;
+ border-top: 0;
+ border-left: 0.5px solid var(--rule);
+ }
+}
+
+.section-heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.section-heading h3 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ color: var(--ink);
+}
+
+.section-heading span {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+}
+
+.message-feed {
+ display: flex;
+ max-height: 310px;
+ flex-direction: column;
+ gap: 8px;
+ overflow: auto;
+}
+
+.message-row {
+ border-top: 0.5px solid var(--rule);
+ padding-top: 8px;
+}
+
+.message-row > div {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 4px;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.message-row strong {
+ font-weight: 500;
+ color: var(--ink);
+}
+
+.message-row span {
+ color: var(--muted);
+}
+
+/* ── Wide grids (agents / profiles / settings) ──── */
+
+.wide-grid,
+.profile-grid {
+ display: grid;
+ gap: 14px;
+}
+
+.profile-grid {
+ grid-template-columns: minmax(0, 1fr) 420px;
+}
+
+.agent-table {
+ display: flex;
+ flex-direction: column;
+}
+
+.agent-row,
+.profile-row {
+ display: grid;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 0;
+ border-top: 0.5px solid var(--rule);
+}
+
+.agent-row:first-of-type,
+.profile-row:first-of-type {
+ border-top: 0;
+ padding-top: 6px;
+}
+
+.agent-row {
+ grid-template-columns: minmax(160px, 1fr) auto minmax(240px, auto);
+}
+
+.agent-row.offline {
+ color: var(--muted);
+}
+
+.agent-row.offline strong {
+ color: var(--ink-soft);
+}
+
+.profile-row {
+ grid-template-columns: minmax(160px, 1fr) auto auto auto;
+}
+
+.agent-row strong,
+.profile-row strong {
+ display: block;
+ margin-bottom: 2px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--ink);
+}
+
+.agent-row span,
+.profile-row span {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.02em;
+}
+
+.agent-row .session-meta {
+ display: block;
+ margin-top: 2px;
+ color: var(--ink-soft);
+ font-size: 10px;
+ text-transform: uppercase;
+}
+
+.editor-panel {
+ align-self: start;
+ gap: 10px;
+}
+
+.log-panel pre {
+ overflow: auto;
+ min-height: 220px;
+ max-height: 420px;
+ margin: 0;
+ border-radius: var(--radius-sm);
+ background: var(--card);
+ color: var(--card-ink);
+ padding: 14px;
+ font-family: var(--mono);
+ font-size: 11px;
+ line-height: 1.6;
+ letter-spacing: 0.01em;
+}
+
+/* ── Profile wizard ──────────────────────────────── */
+
+.wizard-step {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 14px 0;
+ border-top: 0.5px solid var(--rule);
+}
+
+.wizard-step:first-of-type {
+ border-top: 0;
+ padding-top: 6px;
+}
+
+.wizard-step.disabled {
+ opacity: 0.55;
+}
+
+.wizard-step-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.step-index {
+ display: inline-grid;
+ width: 18px;
+ height: 18px;
+ place-items: center;
+ border-radius: 50%;
+ background: var(--card);
+ color: var(--card-ink);
+ font-family: var(--mono);
+ font-size: 10px;
+ font-weight: 500;
+}
+
+.wizard-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 8px;
+}
+
+.wizard-tile {
+ display: grid;
+ align-items: center;
+ grid-template-columns: 32px minmax(0, 1fr);
+ gap: 10px;
+ min-height: auto;
+ padding: 10px 12px;
+ border: 0.5px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--surface);
+ text-align: left;
+ font-family: var(--font);
+ font-size: 12px;
+ cursor: pointer;
+ transition: border-color 120ms ease, background 120ms ease;
+}
+
+.wizard-tile:hover:not(:disabled) {
+ border-color: var(--ink);
+}
+
+.wizard-tile.selected {
+ border-color: var(--ink);
+ background: var(--card);
+ color: var(--card-ink);
+}
+
+.wizard-tile.selected .wizard-meta strong {
+ color: var(--card-ink);
+}
+
+.wizard-tile.selected .wizard-meta span {
+ color: var(--card-muted);
+}
+
+.wizard-tile.selected .wizard-icon {
+ background: rgba(243, 239, 230, 0.12);
+}
+
+.wizard-tile:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.wizard-icon {
+ display: grid;
+ width: 32px;
+ height: 32px;
+ place-items: center;
+ overflow: hidden;
+ border-radius: 7px;
+ background: var(--bg-deep);
+}
+
+.wizard-icon img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ display: block;
+}
+
+.wizard-fallback {
+ font-family: var(--mono);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ color: var(--ink);
+}
+
+.wizard-meta {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.wizard-meta strong {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--ink);
+}
+
+.wizard-meta span {
+ overflow: hidden;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.preset-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.preset-row {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 10px 12px;
+ border: 0.5px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--surface);
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 120ms ease, background 120ms ease;
+}
+
+.preset-row:hover:not(:disabled) {
+ border-color: var(--ink);
+}
+
+.preset-row.selected {
+ border-color: var(--ink);
+ background: var(--bg-deep);
+}
+
+.preset-row:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.preset-row-main {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.preset-row-main strong {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--ink);
+}
+
+.preset-source {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 9px;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+}
+
+.preset-row-desc {
+ margin: 0;
+ color: var(--ink-soft);
+ font-size: 12px;
+ line-height: 1.4;
+}
+
+.preset-row-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.02em;
+}
+
+.preset-row-meta em {
+ font-style: normal;
+ color: var(--ink-soft);
+ font-weight: 500;
+ margin-right: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 9px;
+}
+
+.wizard-name-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.wizard-name-row input {
+ flex: 1;
+}
+
+/* ── Settings / runtime ──────────────────────────── */
+
+.settings-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ max-width: 880px;
+}
+
+.runtime-card {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ border: 0.5px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--surface);
+ padding: 22px 24px;
+}
+
+.runtime-card-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+ flex-wrap: wrap;
+}
+
+.runtime-card-head .eyebrow {
+ margin-bottom: 6px;
+}
+
+.runtime-version {
+ margin: 0;
+ font-size: 28px;
+ font-weight: 500;
+ letter-spacing: -0.02em;
+ color: var(--ink);
+ font-feature-settings: "tnum" 1;
+}
+
+.runtime-card-head p {
+ margin: 6px 0 0;
+ font-size: 12px;
+}
+
+.runtime-card-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.install-log {
+ margin: 0;
+}
+
+.install-log summary {
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+ color: var(--muted);
+ cursor: pointer;
+ list-style: none;
+}
+
+.install-log summary::-webkit-details-marker {
+ display: none;
+}
+
+.install-log[open] summary {
+ margin-bottom: 10px;
+ color: var(--ink);
+}
+
+.install-log pre {
+ overflow: auto;
+ margin: 0;
+ max-height: 280px;
+ border-radius: var(--radius-sm);
+ background: var(--card);
+ color: var(--card-ink);
+ padding: 14px;
+ font-family: var(--mono);
+ font-size: 11px;
+ line-height: 1.6;
+}
+
+.settings-section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.client-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 10px;
+}
+
+.client-tile {
+ display: grid;
+ align-items: center;
+ grid-template-columns: 38px minmax(0, 1fr) auto;
+ gap: 12px;
+ border: 0.5px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--surface);
+ padding: 12px 14px;
+ transition: border-color 120ms ease, background 120ms ease;
+}
+
+.client-tile:hover {
+ border-color: var(--rule-strong);
+}
+
+.client-tile.unavailable {
+ background: transparent;
+ opacity: 0.55;
+}
+
+.client-icon {
+ display: grid;
+ width: 38px;
+ height: 38px;
+ place-items: center;
+ border-radius: 9px;
+ background: var(--bg-deep);
+ overflow: hidden;
+}
+
+.client-icon img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ display: block;
+}
+
+.client-fallback {
+ font-family: var(--mono);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--ink);
+ letter-spacing: 0.04em;
+}
+
+.client-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.client-meta strong {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--ink);
+}
+
+.client-meta span {
+ overflow: hidden;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.06em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.client-status {
+ font-family: var(--mono);
+ font-size: 9px;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--green);
+}
+
+.client-status.off {
+ color: var(--muted);
+}
+
+/* ── Modal ───────────────────────────────────────── */
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ background: rgba(13, 13, 13, 0.36);
+ backdrop-filter: blur(2px);
+ z-index: 50;
+ animation: modal-fade 140ms ease-out;
+}
+
+@keyframes modal-fade {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.modal {
+ width: min(520px, calc(100vw - 48px));
+ max-height: calc(100vh - 80px);
+ display: flex;
+ flex-direction: column;
+ border: 0.5px solid var(--ink);
+ border-radius: 10px;
+ background: var(--surface);
+ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
+ animation: modal-rise 180ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+@keyframes modal-rise {
+ from {
+ opacity: 0;
+ transform: translateY(12px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.modal-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 18px 20px 14px;
+ border-bottom: 0.5px solid var(--rule);
+}
+
+.modal-head .eyebrow {
+ margin-bottom: 4px;
+}
+
+.modal-head h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 500;
+ letter-spacing: -0.015em;
+ color: var(--ink);
+}
+
+.modal-body {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 18px 20px;
+ overflow: auto;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.field > span {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+}
+
+.modal-foot {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 14px 20px 18px;
+ border-top: 0.5px solid var(--rule);
+}
+
+/* ── Settings trigger + popover ──────────────────── */
+
+.settings-trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ margin-top: auto;
+ height: 34px;
+ padding: 0 12px 0 10px;
+ border: 0.5px solid var(--rule);
+ border-radius: var(--radius);
+ background: var(--surface);
+ color: var(--ink-soft);
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.02em;
+ text-align: left;
+ transition: border-color 120ms ease;
+}
+
+.settings-trigger:hover,
+.settings-trigger.open {
+ border-color: var(--ink);
+}
+
+.settings-trigger-label {
+ flex: 1;
+ font-feature-settings: "tnum" 1;
+}
+
+.settings-trigger-glyph {
+ font-size: 13px;
+ color: var(--muted);
+ line-height: 1;
+}
+
+.settings-popover {
+ position: fixed;
+ left: 22px;
+ bottom: 64px;
+ width: min(440px, calc(100vw - 44px));
+ max-height: calc(100vh - 110px);
+ display: flex;
+ flex-direction: column;
+ border: 0.5px solid var(--ink);
+ border-radius: 12px;
+ background: var(--surface);
+ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.16);
+ z-index: 45;
+ overflow: hidden;
+ animation: settings-rise 180ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+@keyframes settings-rise {
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.settings-popover-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 12px 16px 8px;
+ border-bottom: 0.5px solid var(--rule);
+}
+
+.settings-popover-body {
+ overflow: auto;
+ padding: 14px 16px 16px;
+}
+
+/* Tighten the embedded SettingsView so it reads as a popover, not a page. */
+.settings-popover-body .settings-stack {
+ gap: 14px;
+ max-width: none;
+}
+
+.settings-popover-body .runtime-card {
+ padding: 14px 16px;
+ gap: 12px;
+}
+
+.settings-popover-body .runtime-version {
+ font-size: 22px;
+}
+
+.settings-popover-body .runtime-card-head {
+ gap: 12px;
+}
+
+.settings-popover-body .client-grid {
+ grid-template-columns: 1fr;
+ gap: 6px;
+}
+
+.settings-popover-body .client-tile {
+ padding: 8px 10px;
+}
+
+.settings-popover-body .client-icon {
+ width: 30px;
+ height: 30px;
+ border-radius: 7px;
+}
+
+/* ── Responsive ──────────────────────────────────── */
+
+@media (max-width: 1180px) {
+ .app-shell {
+ grid-template-columns: 220px 1fr;
+ }
+
+ .board-layout,
+ .profile-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .kanban {
+ grid-template-columns: repeat(2, minmax(220px, 1fr));
+ }
+}
+
+@media (max-width: 760px) {
+ body {
+ overflow: auto;
+ }
+
+ .app-shell {
+ height: auto;
+ min-height: 100%;
+ grid-template-columns: 1fr;
+ }
+
+ .sidebar {
+ border-right: 0;
+ border-bottom: 0.5px solid var(--rule);
+ }
+
+ .metrics-band,
+ .kanban,
+ .agent-row,
+ .profile-row {
+ grid-template-columns: 1fr;
+ }
+
+ .kanban {
+ height: auto;
+ }
+
+ .kanban-column {
+ height: min(440px, calc(100vh - 220px));
+ min-height: 320px;
+ }
+
+ .topbar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .main-panel {
+ padding: 22px 20px 28px;
+ }
+}
diff --git a/dashboard/vite.config.mjs b/dashboard/vite.config.mjs
new file mode 100644
index 00000000..52c8b2f9
--- /dev/null
+++ b/dashboard/vite.config.mjs
@@ -0,0 +1,38 @@
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const BOARD_PORT = Number(process.env.CLAWTEAM_BOARD_PORT) || 8780;
+const BOARD_TARGET = `http://127.0.0.1:${BOARD_PORT}`;
+
+export default defineConfig({
+ root: __dirname,
+ base: "./",
+ plugins: [react()],
+ server: {
+ proxy: {
+ "/api": {
+ target: BOARD_TARGET,
+ changeOrigin: true,
+ ws: false,
+ // SSE: disable response buffering so events flush immediately
+ configure: (proxy) => {
+ proxy.on("proxyRes", (proxyRes) => {
+ if ((proxyRes.headers["content-type"] || "").includes("text/event-stream")) {
+ proxyRes.headers["cache-control"] = "no-cache";
+ }
+ });
+ },
+ },
+ },
+ },
+ build: {
+ outDir: resolve(__dirname, "../clawteam/board/static"),
+ emptyOutDir: true,
+ assetsDir: "assets",
+ },
+});
diff --git a/desktop/electron/main.mjs b/desktop/electron/main.mjs
new file mode 100644
index 00000000..d7678d8f
--- /dev/null
+++ b/desktop/electron/main.mjs
@@ -0,0 +1,652 @@
+import fs from "node:fs";
+import net from "node:net";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { spawn, spawnSync } from "node:child_process";
+import { app, BrowserWindow, ipcMain, shell } from "electron";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = path.resolve(__dirname, "..", "..");
+const DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
+const PYPI_URL = "https://pypi.org/pypi/clawteam/json";
+const CLAWTEAM_HOME = path.join(os.homedir(), ".clawteam");
+const VENV_PATH = path.join(CLAWTEAM_HOME, ".venv");
+const BIN_DIR = path.join(os.homedir(), ".local", "bin");
+const BOARD_HOST = "127.0.0.1";
+const BOARD_PREFERRED_PORT = Number(process.env.CLAWTEAM_BOARD_PORT) || 8780;
+const BOARD_LOG_LIMIT = 200;
+
+let mainWindow = null;
+
+if (!app.requestSingleInstanceLock()) {
+ app.quit();
+}
+
+const boardManager = createBoardManager();
+
+function createBoardManager() {
+ const state = {
+ child: null,
+ port: 0,
+ status: "idle", // idle | starting | running | stopped | failed
+ error: null,
+ logs: [],
+ startingPromise: null,
+ };
+
+ function appendLog(line) {
+ if (!line) return;
+ state.logs.push(line);
+ if (state.logs.length > BOARD_LOG_LIMIT) {
+ state.logs.splice(0, state.logs.length - BOARD_LOG_LIMIT);
+ }
+ }
+
+ function snapshot() {
+ return {
+ status: state.status,
+ port: state.port,
+ url: state.port ? `http://${BOARD_HOST}:${state.port}/` : "",
+ error: state.error,
+ logs: state.logs.slice(-50),
+ };
+ }
+
+ async function ensure() {
+ if (state.status === "running" && state.child && !state.child.killed) {
+ return snapshot();
+ }
+ if (state.startingPromise) return state.startingPromise;
+
+ state.startingPromise = (async () => {
+ const cmd = resolveClawTeamCommand();
+ if (!cmd) {
+ state.status = "failed";
+ state.error = "clawteam command was not found. Install it from the Settings panel.";
+ appendLog(state.error);
+ return snapshot();
+ }
+
+ state.status = "starting";
+ state.error = null;
+
+ const port = await pickPort(BOARD_PREFERRED_PORT);
+ state.port = port;
+
+ const child = spawn(
+ cmd,
+ ["board", "serve", "--host", BOARD_HOST, "--port", String(port), "--interval", "2"],
+ {
+ env: commandEnv(),
+ cwd: REPO_ROOT,
+ stdio: ["ignore", "pipe", "pipe"],
+ },
+ );
+ state.child = child;
+ appendLog(`spawn ${cmd} board serve --host ${BOARD_HOST} --port ${port}`);
+
+ child.stdout.on("data", (chunk) => appendLog(`[board] ${String(chunk).trimEnd()}`));
+ child.stderr.on("data", (chunk) => appendLog(`[board:err] ${String(chunk).trimEnd()}`));
+ child.on("error", (error) => {
+ appendLog(`[board:error] ${error.message}`);
+ });
+ child.on("exit", (code, signal) => {
+ appendLog(`[board] exited code=${code ?? "null"} signal=${signal ?? "null"}`);
+ if (state.child === child) {
+ state.child = null;
+ if (state.status !== "stopped") {
+ state.status = code === 0 ? "stopped" : "failed";
+ if (state.status === "failed") {
+ state.error = `board server exited (code=${code ?? "?"})`;
+ }
+ }
+ }
+ });
+
+ try {
+ await waitForBoardReady(port, 15_000);
+ state.status = "running";
+ } catch (error) {
+ state.status = "failed";
+ state.error = error.message;
+ appendLog(`[board:ready] ${error.message}`);
+ try {
+ child.kill("SIGTERM");
+ } catch {
+ /* ignore */
+ }
+ }
+
+ return snapshot();
+ })();
+
+ try {
+ return await state.startingPromise;
+ } finally {
+ state.startingPromise = null;
+ }
+ }
+
+ function stop() {
+ const child = state.child;
+ if (!child || child.killed) {
+ state.status = "stopped";
+ state.child = null;
+ return;
+ }
+ state.status = "stopped";
+ try {
+ child.kill("SIGTERM");
+ } catch {
+ /* ignore */
+ }
+ setTimeout(() => {
+ if (state.child === child && !child.killed) {
+ try {
+ child.kill("SIGKILL");
+ } catch {
+ /* ignore */
+ }
+ }
+ }, 1500);
+ }
+
+ return { ensure, stop, snapshot };
+}
+
+function pickPort(preferred) {
+ return tryPort(preferred).catch(() => tryPort(0));
+}
+
+function tryPort(port) {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.unref();
+ server.once("error", reject);
+ server.listen({ host: BOARD_HOST, port }, () => {
+ const address = server.address();
+ const chosen = typeof address === "object" && address ? address.port : port;
+ server.close(() => resolve(chosen));
+ });
+ });
+}
+
+async function waitForBoardReady(port, timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+ let lastError = null;
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(`http://${BOARD_HOST}:${port}/api/overview`, {
+ signal: AbortSignal.timeout(2000),
+ });
+ if (response.ok) return;
+ lastError = new Error(`HTTP ${response.status}`);
+ } catch (error) {
+ lastError = error;
+ }
+ await new Promise((r) => setTimeout(r, 250));
+ }
+ throw new Error(`board server did not become ready: ${lastError?.message || "timeout"}`);
+}
+
+function loadRendererURL(win) {
+ if (DEV_SERVER_URL) {
+ void win.loadURL(DEV_SERVER_URL);
+ return;
+ }
+ const snap = boardManager.snapshot();
+ if (snap.url) {
+ void win.loadURL(snap.url);
+ } else {
+ void win.loadFile(path.join(REPO_ROOT, "clawteam", "board", "static", "index.html"));
+ }
+}
+
+function createWindow() {
+ mainWindow = new BrowserWindow({
+ width: 1320,
+ height: 860,
+ minWidth: 1040,
+ minHeight: 720,
+ backgroundColor: "#f6f4ef",
+ title: "ClawTeam",
+ webPreferences: {
+ preload: path.join(__dirname, "preload.cjs"),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: false,
+ },
+ });
+
+ loadRendererURL(mainWindow);
+}
+
+app.whenReady().then(async () => {
+ await boardManager.ensure().catch(() => null);
+ createWindow();
+});
+
+app.on("window-all-closed", () => {
+ if (process.platform !== "darwin") app.quit();
+});
+
+app.on("activate", () => {
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
+});
+
+app.on("before-quit", () => {
+ boardManager.stop();
+});
+
+for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
+ process.on(signal, () => {
+ boardManager.stop();
+ app.quit();
+ });
+}
+
+function buildShellPath() {
+ const home = os.homedir();
+ const segments = [
+ path.join(REPO_ROOT, ".venv", "bin"),
+ path.join(home, ".clawteam", ".venv", "bin"),
+ path.join(home, ".local", "bin"),
+ path.join(home, "bin"),
+ "/opt/homebrew/bin",
+ "/opt/homebrew/sbin",
+ "/usr/local/bin",
+ "/usr/bin",
+ "/bin",
+ "/usr/sbin",
+ "/sbin",
+ ];
+ if (process.env.PATH) segments.push(...process.env.PATH.split(":"));
+ return [...new Set(segments.filter(Boolean))].join(":");
+}
+
+function commandEnv() {
+ return { ...process.env, PATH: buildShellPath() };
+}
+
+function resolveCommandPath(command) {
+ if (!command) return "";
+ const result = spawnSync("/bin/sh", ["-lc", `command -v ${shellQuote(command)}`], {
+ env: commandEnv(),
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ });
+ if (result.status !== 0) return "";
+ return String(result.stdout || "").trim().split(/\r?\n/).pop() || "";
+}
+
+function resolveClawTeamCommand() {
+ const candidates = [
+ path.join(REPO_ROOT, ".venv", "bin", "clawteam"),
+ path.join(VENV_PATH, "bin", "clawteam"),
+ path.join(BIN_DIR, "clawteam"),
+ ];
+ for (const candidate of candidates) {
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ return resolveCommandPath("clawteam");
+}
+
+function shellQuote(value) {
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
+}
+
+function parseVersion(raw) {
+ // Accept output like "clawteam v0.3.0" — `v` butts against the digits, so we
+ // can't anchor on `\b` before the number; instead require a non-digit (or
+ // start of string) immediately before, which keeps numbers inside paths from
+ // matching while still capturing the leading "0".
+ const match = String(raw || "").match(/(?:^|[^\d.])(\d+\.\d+(?:\.\d+)?(?:[\w.+-]*)?)/);
+ return match ? match[1] : null;
+}
+
+function projectVersion() {
+ try {
+ const pyproject = fs.readFileSync(path.join(REPO_ROOT, "pyproject.toml"), "utf8");
+ return pyproject.match(/^version\s*=\s*"([^"]+)"/m)?.[1] || "";
+ } catch {
+ return "";
+ }
+}
+
+function normalizeRuntimeVersion(value) {
+ const text = String(value || "").trim();
+ const local = projectVersion();
+ if (local && text && local.endsWith(text)) return local;
+ return text || null;
+}
+
+function versionKey(value) {
+ return String(value || "")
+ .match(/\d+/g)
+ ?.slice(0, 4)
+ .map((part) => Number(part)) || [];
+}
+
+function isNewer(latest, current) {
+ const l = versionKey(latest);
+ const c = versionKey(current);
+ if (!l.length || !c.length) return false;
+ for (let index = 0; index < Math.max(l.length, c.length); index += 1) {
+ const diff = (l[index] || 0) - (c[index] || 0);
+ if (diff !== 0) return diff > 0;
+ }
+ return false;
+}
+
+function currentVersion() {
+ const cmd = resolveClawTeamCommand();
+ if (!cmd) return null;
+ const result = spawnSync(cmd, ["--version"], {
+ env: commandEnv(),
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ timeout: 8000,
+ });
+ return normalizeRuntimeVersion(parseVersion(`${result.stdout || ""} ${result.stderr || ""}`));
+}
+
+async function latestVersion() {
+ try {
+ const response = await fetch(PYPI_URL, {
+ headers: { Accept: "application/json" },
+ signal: AbortSignal.timeout(4000),
+ });
+ if (!response.ok) return null;
+ const payload = await response.json();
+ return normalizeRuntimeVersion(payload?.info?.version);
+ } catch {
+ return null;
+ }
+}
+
+async function runtimeStatus() {
+ const commandPath = resolveClawTeamCommand();
+ const current = currentVersion();
+ const latest = await latestVersion();
+ const displayLatest = isNewer(current, latest) ? current : latest;
+ return {
+ installed: Boolean(commandPath || current),
+ current_version: current,
+ latest_version: displayLatest,
+ upgrade_available: isNewer(latest, current),
+ command_path: commandPath,
+ install_root: CLAWTEAM_HOME,
+ platform: process.platform,
+ source: "pypi",
+ };
+}
+
+function runLogged(file, args, logs, options = {}) {
+ return new Promise((resolve) => {
+ const child = spawn(file, args, {
+ env: commandEnv(),
+ cwd: options.cwd || REPO_ROOT,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ child.stdout.on("data", (chunk) => logs.push(String(chunk).trimEnd()));
+ child.stderr.on("data", (chunk) => logs.push(String(chunk).trimEnd()));
+ child.on("error", (error) => {
+ logs.push(`Error: ${error.message}`);
+ resolve({ code: 1 });
+ });
+ child.on("close", (code) => resolve({ code: code || 0 }));
+ });
+}
+
+function findPython() {
+ for (const candidate of ["python3.12", "python3.11", "python3.10", "python3"]) {
+ const resolved = resolveCommandPath(candidate);
+ if (!resolved) continue;
+ const result = spawnSync(resolved, ["-c", "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)"]);
+ if (result.status === 0) return resolved;
+ }
+ return "";
+}
+
+async function installOrUpgradeClawTeam() {
+ const logs = [];
+ const python = findPython();
+ if (!python) {
+ return { ok: false, logs: ["Python 3.10+ was not found. Install Python and retry."] };
+ }
+ fs.mkdirSync(CLAWTEAM_HOME, { recursive: true });
+ fs.mkdirSync(BIN_DIR, { recursive: true });
+ logs.push(`Using Python: ${python}`);
+
+ if (!fs.existsSync(path.join(VENV_PATH, "bin", "python"))) {
+ logs.push(`Creating virtual environment at ${VENV_PATH}`);
+ const venv = await runLogged(python, ["-m", "venv", VENV_PATH], logs);
+ if (venv.code !== 0) return { ok: false, logs };
+ }
+
+ const pip = path.join(VENV_PATH, "bin", "pip");
+ logs.push("Upgrading pip");
+ const pipResult = await runLogged(pip, ["install", "--upgrade", "pip"], logs);
+ if (pipResult.code !== 0) return { ok: false, logs };
+
+ logs.push("Installing latest clawteam from PyPI");
+ const install = await runLogged(pip, ["install", "--upgrade", "clawteam"], logs);
+ if (install.code !== 0) return { ok: false, logs };
+
+ const target = path.join(VENV_PATH, "bin", "clawteam");
+ const link = path.join(BIN_DIR, "clawteam");
+ try {
+ fs.rmSync(link, { force: true });
+ fs.symlinkSync(target, link);
+ logs.push(`Linked ${link} -> ${target}`);
+ } catch (error) {
+ logs.push(`Could not link ${link}: ${error.message}`);
+ }
+ logs.push("ClawTeam runtime is ready.");
+ return { ok: true, logs };
+}
+
+function runClawTeam(args) {
+ const cmd = resolveClawTeamCommand();
+ if (!cmd) throw new Error("clawteam command was not found. Install ClawTeam first.");
+ const result = spawnSync(cmd, args, {
+ env: commandEnv(),
+ cwd: REPO_ROOT,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ timeout: 20_000,
+ });
+ if (result.status !== 0) {
+ throw new Error((result.stderr || result.stdout || "clawteam command failed").trim());
+ }
+ return result.stdout;
+}
+
+function jsonClawTeam(args) {
+ const stdout = runClawTeam(["--json", ...args]);
+ return JSON.parse(stdout || "{}");
+}
+
+function profileArgs(name, profile) {
+ const args = ["profile", "set", name];
+ const stringFields = [
+ ["agent", "--agent"],
+ ["description", "--description"],
+ ["model", "--model"],
+ ["base_url", "--base-url"],
+ ["base_url_env", "--base-url-env"],
+ ["api_key_env", "--api-key-env"],
+ ["api_key_target_env", "--api-key-target-env"],
+ ];
+ for (const [field, flag] of stringFields) {
+ if (profile[field]) args.push(flag, String(profile[field]));
+ }
+ if (Array.isArray(profile.command) && profile.command.length) {
+ args.push("--command", profile.command.join(" "));
+ }
+ for (const [key, value] of Object.entries(profile.env || {})) args.push("--env", `${key}=${value}`);
+ for (const [key, value] of Object.entries(profile.env_map || {})) args.push("--env-map", `${key}=${value}`);
+ for (const value of profile.args || []) args.push("--arg", String(value));
+ return args;
+}
+
+function appExists(name) {
+ return (
+ fs.existsSync(`/Applications/${name}.app`) ||
+ fs.existsSync(path.join(os.homedir(), "Applications", `${name}.app`))
+ );
+}
+
+function launchTargets() {
+ return [
+ { id: "terminal", label: "Terminal", description: "Attach tmux in Terminal.app", available: process.platform === "darwin" },
+ { id: "iterm", label: "iTerm2", description: "Attach tmux in iTerm2", available: appExists("iTerm") || appExists("iTerm2") },
+ { id: "ghostty", label: "Ghostty", description: "Attach tmux in Ghostty", available: appExists("Ghostty") || Boolean(resolveCommandPath("ghostty")) },
+ { id: "vscode", label: "VS Code", description: "Open the repository in VS Code", available: Boolean(resolveCommandPath("code")) || appExists("Visual Studio Code") },
+ { id: "cursor", label: "Cursor", description: "Open the repository in Cursor", available: Boolean(resolveCommandPath("cursor")) || appExists("Cursor") },
+ ];
+}
+
+function appleScriptString(value) {
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
+}
+
+function tmuxAttachCommand(payload) {
+ const team = payload?.team || "default";
+ const session = payload?.session || {};
+ const target = session.tmuxTarget || session.target || `clawteam-${team}`;
+ const tmuxSession = String(target).split(":")[0] || `clawteam-${team}`;
+ return `tmux attach -t ${shellQuote(tmuxSession)}`;
+}
+
+function openTerminalCommand(command, terminal) {
+ if (terminal === "iterm") {
+ const script = [
+ 'tell application "iTerm"',
+ "activate",
+ "create window with default profile",
+ `tell current session of current window to write text ${appleScriptString(command)}`,
+ "end tell",
+ ];
+ return spawnSync("osascript", script.flatMap((line) => ["-e", line]), { env: commandEnv() });
+ }
+ const script = [
+ 'tell application "Terminal"',
+ "activate",
+ `do script ${appleScriptString(command)}`,
+ "end tell",
+ ];
+ return spawnSync("osascript", script.flatMap((line) => ["-e", line]), { env: commandEnv() });
+}
+
+function openApp(appName, targetPath = REPO_ROOT) {
+ return spawnSync("open", ["-a", appName, targetPath], { env: commandEnv() });
+}
+
+ipcMain.handle("runtime:status", runtimeStatus);
+ipcMain.handle("runtime:install", async () => {
+ const result = await installOrUpgradeClawTeam();
+ if (result.ok) {
+ boardManager.ensure().catch(() => null);
+ }
+ return result;
+});
+ipcMain.handle("runtime:upgrade", async () => {
+ const result = await installOrUpgradeClawTeam();
+ if (result.ok) {
+ boardManager.ensure().catch(() => null);
+ }
+ return result;
+});
+ipcMain.handle("board:status", () => boardManager.snapshot());
+ipcMain.handle("board:ensure", () => boardManager.ensure());
+ipcMain.handle("board:restart", async () => {
+ boardManager.stop();
+ await new Promise((r) => setTimeout(r, 300));
+ return boardManager.ensure();
+});
+ipcMain.handle("profiles:list", () => jsonClawTeam(["profile", "list"]));
+ipcMain.handle("profiles:save", (_event, payload) => {
+ const name = String(payload?.name || "").trim();
+ if (!name) throw new Error("Profile name is required.");
+ runClawTeam(profileArgs(name, payload.profile || {}));
+ return { ok: true, profiles: jsonClawTeam(["profile", "list"]) };
+});
+ipcMain.handle("profiles:remove", (_event, payload) => {
+ const name = String(payload?.name || "").trim();
+ if (!name) throw new Error("Profile name is required.");
+ runClawTeam(["profile", "remove", name]);
+ return { ok: true, profiles: jsonClawTeam(["profile", "list"]) };
+});
+ipcMain.handle("profiles:test", (_event, payload) => {
+ const name = String(payload?.name || "").trim();
+ if (!name) throw new Error("Profile name is required.");
+ return { ok: true, result: jsonClawTeam(["profile", "test", name]) };
+});
+ipcMain.handle("presets:list", () => jsonClawTeam(["preset", "list"]));
+ipcMain.handle("presets:generate", (_event, payload) => {
+ const presetName = String(payload?.preset || "").trim();
+ const client = String(payload?.client || "").trim();
+ const name = String(payload?.name || "").trim();
+ if (!presetName) throw new Error("Preset name is required.");
+ if (!client) throw new Error("Client is required.");
+ const args = ["preset", "generate-profile", presetName, client, "--force"];
+ if (name) args.push("--name", name);
+ const result = jsonClawTeam(args);
+ return { ok: true, result, profiles: jsonClawTeam(["profile", "list"]) };
+});
+ipcMain.handle("sessions:list-launch-targets", launchTargets);
+ipcMain.handle("sessions:open", async (_event, payload) => {
+ const target = String(payload?.target || "");
+ const session = payload?.session || {};
+ if (session.alive !== true && (target === "terminal" || target === "iterm" || target === "ghostty")) {
+ throw new Error(`Session '${session.agentName || "agent"}' is offline.`);
+ }
+ if (target === "terminal" || target === "iterm") {
+ if (session.backend !== "tmux") {
+ throw new Error("Terminal attach is available for tmux sessions.");
+ }
+ const result = openTerminalCommand(tmuxAttachCommand(payload), target);
+ if (result.status !== 0) throw new Error(result.stderr?.toString() || "Failed to open terminal.");
+ return { ok: true, message: `Opening ${session.agentName || "agent"} in ${target}.` };
+ }
+ if (target === "ghostty") {
+ if (session.backend !== "tmux") {
+ throw new Error("Ghostty attach is available for tmux sessions.");
+ }
+ if (!appExists("Ghostty")) {
+ throw new Error("Ghostty is not installed.");
+ }
+ const command = tmuxAttachCommand(payload);
+ spawnSync("osascript", [
+ "-e",
+ 'tell application "Ghostty" to activate',
+ "-e",
+ "delay 0.3",
+ "-e",
+ 'tell application "System Events" to keystroke "t" using command down',
+ "-e",
+ "delay 0.2",
+ "-e",
+ `tell application "System Events" to keystroke "${command.replace(/"/g, '\\"')}"`,
+ "-e",
+ 'tell application "System Events" to key code 36',
+ ], { env: commandEnv() });
+ return { ok: true, message: `Opening ${session.agentName || "agent"} in Ghostty.` };
+ }
+ if (target === "vscode") {
+ const code = resolveCommandPath("code");
+ if (code) spawn(code, [REPO_ROOT], { detached: true, stdio: "ignore", env: commandEnv() }).unref();
+ else openApp("Visual Studio Code", REPO_ROOT);
+ return { ok: true, message: "Opening repository in VS Code." };
+ }
+ if (target === "cursor") {
+ const cursor = resolveCommandPath("cursor");
+ if (cursor) spawn(cursor, [REPO_ROOT], { detached: true, stdio: "ignore", env: commandEnv() }).unref();
+ else openApp("Cursor", REPO_ROOT);
+ return { ok: true, message: "Opening repository in Cursor." };
+ }
+ await shell.openPath(REPO_ROOT);
+ return { ok: true, message: "Opening repository folder." };
+});
diff --git a/desktop/electron/preload.cjs b/desktop/electron/preload.cjs
new file mode 100644
index 00000000..8612b011
--- /dev/null
+++ b/desktop/electron/preload.cjs
@@ -0,0 +1,18 @@
+const { contextBridge, ipcRenderer } = require("electron");
+
+contextBridge.exposeInMainWorld("clawteamDesktop", {
+ getRuntimeStatus: () => ipcRenderer.invoke("runtime:status"),
+ installClawTeam: () => ipcRenderer.invoke("runtime:install"),
+ upgradeClawTeam: () => ipcRenderer.invoke("runtime:upgrade"),
+ listProfiles: () => ipcRenderer.invoke("profiles:list"),
+ saveProfile: (name, profile) => ipcRenderer.invoke("profiles:save", { name, profile }),
+ removeProfile: (name) => ipcRenderer.invoke("profiles:remove", { name }),
+ testProfile: (name) => ipcRenderer.invoke("profiles:test", { name }),
+ listPresets: () => ipcRenderer.invoke("presets:list"),
+ generateProfileFromPreset: (payload) => ipcRenderer.invoke("presets:generate", payload),
+ listLaunchTargets: () => ipcRenderer.invoke("sessions:list-launch-targets"),
+ openSession: (payload) => ipcRenderer.invoke("sessions:open", payload),
+ getBoardStatus: () => ipcRenderer.invoke("board:status"),
+ ensureBoard: () => ipcRenderer.invoke("board:ensure"),
+ restartBoard: () => ipcRenderer.invoke("board:restart"),
+});
diff --git a/package-lock.json b/package-lock.json
index 21049359..c57bca0e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,11 @@
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
- "vite": "^5.4.11"
+ "concurrently": "^9.2.1",
+ "cross-env": "^7.0.3",
+ "electron": "^37.2.6",
+ "vite": "^5.4.11",
+ "wait-on": "^8.0.4"
}
},
"node_modules/@babel/code-frame": {
@@ -47,7 +51,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -299,6 +302,28 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@electron/get": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/@electron/get/-/get-2.0.3.tgz",
+ "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "env-paths": "^2.2.0",
+ "fs-extra": "^8.1.0",
+ "got": "^11.8.5",
+ "progress": "^2.0.3",
+ "semver": "^6.2.0",
+ "sumchecker": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "global-agent": "^3.0.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -690,6 +715,60 @@
"node": ">=12"
}
},
+ "node_modules/@hapi/address": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmmirror.com/@hapi/address/-/address-5.1.1.tgz",
+ "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@hapi/hoek": "^11.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@hapi/formula": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/@hapi/formula/-/formula-3.0.2.tgz",
+ "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@hapi/hoek": {
+ "version": "11.0.7",
+ "resolved": "https://registry.npmmirror.com/@hapi/hoek/-/hoek-11.0.7.tgz",
+ "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@hapi/pinpoint": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/@hapi/pinpoint/-/pinpoint-2.0.1.tgz",
+ "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@hapi/tlds": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmmirror.com/@hapi/tlds/-/tlds-1.1.6.tgz",
+ "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@hapi/topo": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmmirror.com/@hapi/topo/-/topo-6.0.2.tgz",
+ "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@hapi/hoek": "^11.0.2"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1097,6 +1176,39 @@
"win32"
]
},
+ "node_modules/@sindresorhus/is": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz",
+ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@szmarczak/http-timer": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
+ "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "defer-to-connect": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1142,6 +1254,19 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/cacheable-request": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
+ "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-cache-semantics": "*",
+ "@types/keyv": "^3.1.4",
+ "@types/node": "*",
+ "@types/responselike": "^1.0.0"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1149,6 +1274,54 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmmirror.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/keyv": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmmirror.com/@types/keyv/-/keyv-3.1.4.tgz",
+ "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.17",
+ "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.17.tgz",
+ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/responselike": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz",
+ "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/yauzl": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz",
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1170,6 +1343,51 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.15.2",
+ "resolved": "https://registry.npmmirror.com/axios/-/axios-1.15.2.tgz",
+ "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.10",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz",
@@ -1183,6 +1401,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/boolean": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz",
+ "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -1203,7 +1430,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -1218,6 +1444,59 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/cacheable-lookup": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmmirror.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
+ "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.6.0"
+ }
+ },
+ "node_modules/cacheable-request": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmmirror.com/cacheable-request/-/cacheable-request-7.0.4.tgz",
+ "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "clone-response": "^1.0.2",
+ "get-stream": "^5.1.0",
+ "http-cache-semantics": "^4.0.0",
+ "keyv": "^4.0.0",
+ "lowercase-keys": "^2.0.0",
+ "normalize-url": "^6.0.1",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001781",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
@@ -1239,164 +1518,1039 @@
],
"license": "CC-BY-4.0"
},
- "node_modules/convert-source-map": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
- "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ms": "^2.1.3"
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
},
"engines": {
- "node": ">=6.0"
+ "node": ">=10"
},
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/electron-to-chromium": {
- "version": "1.5.321",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
- "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==",
+ "node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
- "license": "ISC"
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
},
- "node_modules/esbuild": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
- "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
+ }
+ },
+ "node_modules/clone-response": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/clone-response/-/clone-response-1.0.3.tgz",
+ "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^1.0.0"
},
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.21.5",
- "@esbuild/android-arm": "0.21.5",
- "@esbuild/android-arm64": "0.21.5",
- "@esbuild/android-x64": "0.21.5",
- "@esbuild/darwin-arm64": "0.21.5",
- "@esbuild/darwin-x64": "0.21.5",
- "@esbuild/freebsd-arm64": "0.21.5",
- "@esbuild/freebsd-x64": "0.21.5",
- "@esbuild/linux-arm": "0.21.5",
- "@esbuild/linux-arm64": "0.21.5",
- "@esbuild/linux-ia32": "0.21.5",
- "@esbuild/linux-loong64": "0.21.5",
- "@esbuild/linux-mips64el": "0.21.5",
- "@esbuild/linux-ppc64": "0.21.5",
- "@esbuild/linux-riscv64": "0.21.5",
- "@esbuild/linux-s390x": "0.21.5",
- "@esbuild/linux-x64": "0.21.5",
- "@esbuild/netbsd-x64": "0.21.5",
- "@esbuild/openbsd-x64": "0.21.5",
- "@esbuild/sunos-x64": "0.21.5",
- "@esbuild/win32-arm64": "0.21.5",
- "@esbuild/win32-ia32": "0.21.5",
- "@esbuild/win32-x64": "0.21.5"
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/escalade": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
- "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
"engines": {
- "node": ">=6"
+ "node": ">=7.0.0"
}
},
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
- "hasInstallScript": true,
"license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
"engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ "node": ">= 0.8"
}
},
- "node_modules/gensync": {
- "version": "1.0.0-beta.2",
- "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
- "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "node_modules/concurrently": {
+ "version": "9.2.1",
+ "resolved": "https://registry.npmmirror.com/concurrently/-/concurrently-9.2.1.tgz",
+ "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "chalk": "4.1.2",
+ "rxjs": "7.8.2",
+ "shell-quote": "1.8.3",
+ "supports-color": "8.1.1",
+ "tree-kill": "1.2.2",
+ "yargs": "17.7.2"
+ },
+ "bin": {
+ "conc": "dist/bin/concurrently.js",
+ "concurrently": "dist/bin/concurrently.js"
+ },
"engines": {
- "node": ">=6.9.0"
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
- "node_modules/js-tokens": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
"license": "MIT"
},
- "node_modules/jsesc": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
- "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "node_modules/cross-env": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmmirror.com/cross-env/-/cross-env-7.0.3.tgz",
+ "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.1"
+ },
"bin": {
- "jsesc": "bin/jsesc"
+ "cross-env": "src/bin/cross-env.js",
+ "cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
- "node": ">=6"
+ "node": ">=10.14",
+ "npm": ">=6",
+ "yarn": ">=1"
}
},
- "node_modules/json5": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
- "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
- "bin": {
- "json5": "lib/cli.js"
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/decompress-response/node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/defer-to-connect": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
+ "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/detect-node": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz",
+ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron": {
+ "version": "37.10.3",
+ "resolved": "https://registry.npmmirror.com/electron/-/electron-37.10.3.tgz",
+ "integrity": "sha512-3IjCGSjQmH50IbW2PFveaTzK+KwcFX9PEhE7KXb9v5IT8cLAiryAN7qezm/XzODhDRlLu0xKG1j8xWBtZ/bx/g==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@electron/get": "^2.0.0",
+ "@types/node": "^22.7.7",
+ "extract-zip": "^2.0.1"
+ },
+ "bin": {
+ "electron": "cli.js"
+ },
+ "engines": {
+ "node": ">= 12.20.55"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.321",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
+ "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es6-error": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz",
+ "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz",
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "extract-zip": "cli.js"
+ },
+ "engines": {
+ "node": ">= 10.17.0"
+ },
+ "optionalDependencies": {
+ "@types/yauzl": "^2.9.1"
+ }
+ },
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz",
+ "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^4.0.0",
+ "universalify": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=6 <7 || >=8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/global-agent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz",
+ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "es6-error": "^4.1.1",
+ "matcher": "^3.0.0",
+ "roarr": "^2.15.3",
+ "semver": "^7.3.2",
+ "serialize-error": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=10.0"
+ }
+ },
+ "node_modules/global-agent/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/got": {
+ "version": "11.8.6",
+ "resolved": "https://registry.npmmirror.com/got/-/got-11.8.6.tgz",
+ "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/is": "^4.0.0",
+ "@szmarczak/http-timer": "^4.0.5",
+ "@types/cacheable-request": "^6.0.1",
+ "@types/responselike": "^1.0.0",
+ "cacheable-lookup": "^5.0.3",
+ "cacheable-request": "^7.0.2",
+ "decompress-response": "^6.0.0",
+ "http2-wrapper": "^1.0.0-beta.5.2",
+ "lowercase-keys": "^2.0.0",
+ "p-cancelable": "^2.0.0",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/got?sponsor=1"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/http2-wrapper": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
+ "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "quick-lru": "^5.1.1",
+ "resolve-alpn": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/joi": {
+ "version": "18.1.2",
+ "resolved": "https://registry.npmmirror.com/joi/-/joi-18.1.2.tgz",
+ "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@hapi/address": "^5.1.1",
+ "@hapi/formula": "^3.0.2",
+ "@hapi/hoek": "^11.0.7",
+ "@hapi/pinpoint": "^2.0.1",
+ "@hapi/tlds": "^1.1.1",
+ "@hapi/topo": "^6.0.2",
+ "@standard-schema/spec": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
- "node_modules/loose-envify": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
- "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "node_modules/jsonfile": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz",
+ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+ "dev": true,
+ "license": "MIT",
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lowercase-keys": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
+ "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/matcher": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz",
+ "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "js-tokens": "^3.0.0 || ^4.0.0"
+ "mime-db": "1.52.0"
},
- "bin": {
- "loose-envify": "cli.js"
+ "engines": {
+ "node": ">= 0.6"
}
},
- "node_modules/lru-cache": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
- "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "node_modules/mimic-response": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz",
+ "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
"dev": true,
- "license": "ISC",
- "dependencies": {
- "yallist": "^3.0.2"
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
@@ -1432,6 +2586,67 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/normalize-url": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmmirror.com/normalize-url/-/normalize-url-6.1.0.tgz",
+ "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/p-cancelable": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz",
+ "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1468,12 +2683,55 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/quick-lru": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz",
+ "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -1504,6 +2762,55 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-alpn": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/responselike": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/responselike/-/responselike-2.0.1.tgz",
+ "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lowercase-keys": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/roarr": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmmirror.com/roarr/-/roarr-2.15.4.tgz",
+ "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "detect-node": "^2.0.4",
+ "globalthis": "^1.0.1",
+ "json-stringify-safe": "^5.0.1",
+ "semver-compare": "^1.0.0",
+ "sprintf-js": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/rollup": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
@@ -1549,6 +2856,16 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -1568,6 +2885,67 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/semver-compare": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/semver-compare/-/semver-compare-1.0.0.tgz",
+ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/serialize-error": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz",
+ "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "type-fest": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shell-quote": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.3.tgz",
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1578,6 +2956,119 @@
"node": ">=0.10.0"
}
},
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/sumchecker": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz",
+ "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/type-fest": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz",
+ "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/universalify": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz",
+ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1615,7 +3106,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -1670,12 +3160,123 @@
}
}
},
+ "node_modules/wait-on": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmmirror.com/wait-on/-/wait-on-8.0.5.tgz",
+ "integrity": "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "axios": "^1.12.1",
+ "joi": "^18.0.1",
+ "lodash": "^4.17.21",
+ "minimist": "^1.2.8",
+ "rxjs": "^7.8.2"
+ },
+ "bin": {
+ "wait-on": "bin/wait-on"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
}
}
}
diff --git a/package.json b/package.json
index 5348f8d5..e6990899 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,13 @@
"scripts": {
"dev": "vite --config website/vite.config.mjs",
"build": "vite build --config website/vite.config.mjs",
- "preview": "vite preview --config website/vite.config.mjs"
+ "preview": "vite preview --config website/vite.config.mjs",
+ "dashboard:dev": "vite --config dashboard/vite.config.mjs --host 127.0.0.1 --port 5174",
+ "dashboard:build": "vite build --config dashboard/vite.config.mjs",
+ "dashboard:preview": "vite preview --config dashboard/vite.config.mjs --host 127.0.0.1 --port 4174",
+ "desktop:dev": "cross-env CLAWTEAM_BOARD_PORT=8780 concurrently -k \"npm run dashboard:dev\" \"wait-on http://127.0.0.1:5174 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5174 CLAWTEAM_BOARD_PORT=8780 electron desktop/electron/main.mjs\"",
+ "desktop:start": "electron desktop/electron/main.mjs",
+ "build:all": "npm run build && npm run dashboard:build"
},
"dependencies": {
"react": "^18.3.1",
@@ -14,6 +20,10 @@
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
+ "concurrently": "^9.2.1",
+ "cross-env": "^7.0.3",
+ "electron": "^37.2.6",
+ "wait-on": "^8.0.4",
"vite": "^5.4.11"
}
}
diff --git a/pyproject.toml b/pyproject.toml
index 2dd15796..a3813207 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,9 @@ dev = [
p2p = [
"pyzmq>=25.0.0,<27.0.0",
]
+redis = [
+ "redis>=5.0.0,<6.0.0",
+]
[project.scripts]
clawteam = "clawteam.cli.commands:app"
diff --git a/scripts/clawteam_local_install b/scripts/clawteam_local_install
new file mode 100755
index 00000000..3cf5a397
--- /dev/null
+++ b/scripts/clawteam_local_install
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+# ClawTeam local source installer.
+#
+# Installs this checkout with `pip install -e .` into ~/.clawteam/.venv and
+# links ~/.local/bin/clawteam to that virtual environment.
+
+set -euo pipefail
+
+CLAWTEAM_HOME="${CLAWTEAM_HOME:-$HOME/.clawteam}"
+VENV_PATH="${CLAWTEAM_HOME}/.venv"
+BIN_DIR="${HOME}/.local/bin"
+PYTHON_BIN="${PYTHON_BIN:-}"
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
+
+log() { printf '%s\n' "$*"; }
+fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
+
+python_version_ok() {
+ local bin="$1"
+ "$bin" - <<'PY' >/dev/null 2>&1
+import sys
+raise SystemExit(0 if sys.version_info >= (3, 10) else 1)
+PY
+}
+
+try_python_bin() {
+ local bin="$1"
+ [ -n "$bin" ] || return 1
+ command -v "$bin" >/dev/null 2>&1 || return 1
+ python_version_ok "$bin" || return 1
+ command -v "$bin"
+}
+
+detect_python_bin() {
+ if [ -n "$PYTHON_BIN" ]; then
+ try_python_bin "$PYTHON_BIN" && return 0
+ log "Requested PYTHON_BIN=$PYTHON_BIN is not Python 3.10+"
+ fi
+ for candidate in python3.12 python3.11 python3.10 python3; do
+ try_python_bin "$candidate" && return 0
+ done
+ return 1
+}
+
+[ -f "${REPO_ROOT}/pyproject.toml" ] || fail "Could not find pyproject.toml at ${REPO_ROOT}"
+
+if ! PYTHON_BIN="$(detect_python_bin)"; then
+ fail "Python 3.10+ is required to install ClawTeam"
+fi
+
+log "Using Python: $("$PYTHON_BIN" --version 2>&1)"
+mkdir -p "$CLAWTEAM_HOME" "$BIN_DIR"
+
+if [ ! -x "${VENV_PATH}/bin/python" ]; then
+ log "Creating virtual environment at ${VENV_PATH}"
+ "$PYTHON_BIN" -m venv "$VENV_PATH"
+fi
+
+if ! python_version_ok "${VENV_PATH}/bin/python"; then
+ fail "Existing virtual environment at ${VENV_PATH} is not Python 3.10+. Remove it and run again."
+fi
+
+log "Installing local ClawTeam checkout from ${REPO_ROOT}"
+"${VENV_PATH}/bin/pip" install --upgrade pip >/dev/null
+"${VENV_PATH}/bin/pip" install -e "${REPO_ROOT}"
+
+ln -sf "${VENV_PATH}/bin/clawteam" "${BIN_DIR}/clawteam"
+log "Linked clawteam -> ${BIN_DIR}/clawteam"
+
+case ":${PATH}:" in
+ *":${BIN_DIR}:"*) ;;
+ *) log "Add ${BIN_DIR} to PATH to use clawteam in every shell." ;;
+esac
+
+log "ClawTeam local install is ready."
diff --git a/scripts/install_clawteam.sh b/scripts/install_clawteam.sh
new file mode 100644
index 00000000..79e832ee
--- /dev/null
+++ b/scripts/install_clawteam.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# ClawTeam user-level installer.
+#
+# Installs the PyPI package into ~/.clawteam/.venv and links ~/.local/bin/clawteam.
+
+set -euo pipefail
+
+CLAWTEAM_HOME="${CLAWTEAM_HOME:-$HOME/.clawteam}"
+VENV_PATH="${CLAWTEAM_HOME}/.venv"
+BIN_DIR="${HOME}/.local/bin"
+PYTHON_BIN="${PYTHON_BIN:-}"
+
+log() { printf '%s\n' "$*"; }
+fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
+
+python_version_ok() {
+ local bin="$1"
+ "$bin" - <<'PY' >/dev/null 2>&1
+import sys
+raise SystemExit(0 if sys.version_info >= (3, 10) else 1)
+PY
+}
+
+try_python_bin() {
+ local bin="$1"
+ [ -n "$bin" ] || return 1
+ command -v "$bin" >/dev/null 2>&1 || return 1
+ python_version_ok "$bin" || return 1
+ command -v "$bin"
+}
+
+detect_python_bin() {
+ if [ -n "$PYTHON_BIN" ]; then
+ try_python_bin "$PYTHON_BIN" && return 0
+ log "Requested PYTHON_BIN=$PYTHON_BIN is not Python 3.10+"
+ fi
+ for candidate in python3.12 python3.11 python3.10 python3; do
+ try_python_bin "$candidate" && return 0
+ done
+ return 1
+}
+
+if ! PYTHON_BIN="$(detect_python_bin)"; then
+ fail "Python 3.10+ is required to install ClawTeam"
+fi
+
+log "Using Python: $("$PYTHON_BIN" --version 2>&1)"
+mkdir -p "$CLAWTEAM_HOME" "$BIN_DIR"
+
+if [ ! -x "${VENV_PATH}/bin/python" ]; then
+ log "Creating virtual environment at ${VENV_PATH}"
+ "$PYTHON_BIN" -m venv "$VENV_PATH"
+fi
+
+log "Installing latest clawteam from PyPI"
+"${VENV_PATH}/bin/pip" install --upgrade pip >/dev/null
+"${VENV_PATH}/bin/pip" install --upgrade clawteam
+
+ln -sf "${VENV_PATH}/bin/clawteam" "${BIN_DIR}/clawteam"
+log "Linked clawteam -> ${BIN_DIR}/clawteam"
+
+case ":${PATH}:" in
+ *":${BIN_DIR}:"*) ;;
+ *) log "Add ${BIN_DIR} to PATH to use clawteam in every shell." ;;
+esac
+
+log "ClawTeam is ready."
diff --git a/scripts/run_board.mjs b/scripts/run_board.mjs
new file mode 100755
index 00000000..cbbaf158
--- /dev/null
+++ b/scripts/run_board.mjs
@@ -0,0 +1,44 @@
+#!/usr/bin/env node
+import { spawn, spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
+
+function findClawTeam() {
+ const candidates = [
+ path.join(REPO_ROOT, ".venv", "bin", "clawteam"),
+ path.join(os.homedir(), ".clawteam", ".venv", "bin", "clawteam"),
+ path.join(os.homedir(), ".local", "bin", "clawteam"),
+ ];
+ for (const p of candidates) if (fs.existsSync(p)) return p;
+ const which = spawnSync("/bin/sh", ["-lc", "command -v clawteam"], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ });
+ if (which.status === 0) return String(which.stdout).trim().split(/\r?\n/).pop();
+ return "";
+}
+
+const team = process.env.CLAWTEAM_TEAM || "demo";
+const port = process.env.CLAWTEAM_BOARD_PORT || "8080";
+const cmd = findClawTeam();
+
+if (!cmd) {
+ console.error("clawteam command not found. Install it (uv pip install -e . / pip install clawteam) or activate the project venv.");
+ process.exit(1);
+}
+
+console.log(`[board] using ${cmd}`);
+console.log(`[board] team=${team} port=${port}`);
+
+const child = spawn(cmd, ["board", "serve", team, "--host", "127.0.0.1", "--port", port], {
+ stdio: "inherit",
+ env: process.env,
+});
+
+const stop = () => child.kill("SIGTERM");
+process.on("SIGINT", stop);
+process.on("SIGTERM", stop);
+child.on("exit", (code) => process.exit(code || 0));
diff --git a/tests/test_board.py b/tests/test_board.py
index c264de33..a2887329 100644
--- a/tests/test_board.py
+++ b/tests/test_board.py
@@ -125,6 +125,47 @@ def test_collect_team_preserves_conflicts_field(monkeypatch, tmp_path: Path):
assert "conflicts" in data
+def test_collect_team_exposes_spawn_sessions(monkeypatch, tmp_path: Path):
+ monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path))
+ TeamManager.create_team(
+ name="demo",
+ leader_name="leader",
+ leader_id="leader001",
+ description="demo team",
+ )
+ TeamManager.add_member("demo", "worker", "worker001")
+
+ from clawteam.spawn.registry import register_agent
+
+ register_agent(
+ "demo",
+ "worker",
+ backend="tmux",
+ tmux_target="clawteam-demo:worker",
+ pid=123,
+ command=["claude"],
+ )
+ monkeypatch.setattr("clawteam.spawn.registry.is_agent_alive", lambda *_: True)
+
+ data = BoardCollector().collect_team("demo")
+
+ assert data["sessions"] == [
+ {
+ "agentName": "worker",
+ "backend": "tmux",
+ "target": "clawteam-demo:worker",
+ "tmuxTarget": "clawteam-demo:worker",
+ "blockId": "",
+ "pid": 123,
+ "command": ["claude"],
+ "spawnedAt": data["sessions"][0]["spawnedAt"],
+ "alive": True,
+ }
+ ]
+ worker = next(member for member in data["members"] if member["name"] == "worker")
+ assert worker["session"] == {"backend": "tmux", "target": "clawteam-demo:worker"}
+
+
def test_collect_team_exposes_member_inbox_identity(monkeypatch, tmp_path: Path):
monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path))
TeamManager.create_team(
@@ -365,15 +406,28 @@ def open(self, req, timeout=10):
assert seen["url"] == "https://raw.githubusercontent.com/org/repo/main/README.md"
-def test_board_ui_escapes_attacker_controlled_fields():
- html = Path("clawteam/board/static/index.html").read_text(encoding="utf-8")
+def test_board_static_path_rejects_escape():
+ handler = object.__new__(BoardHandler)
+
+ assert handler._static_path("../config.json") is None
+
+
+def test_board_react_source_does_not_use_raw_html():
+ source = Path("dashboard/src/App.jsx").read_text(encoding="utf-8")
+
+ assert "dangerouslySetInnerHTML" not in source
+
+
+def test_runtime_status_reports_upgrade(monkeypatch):
+ from clawteam.board import runtime
+
+ monkeypatch.setattr(runtime, "_installed_version", lambda: "0.3.0")
+ monkeypatch.setattr(runtime, "_latest_pypi_version", lambda timeout: "0.4.0")
+ monkeypatch.setattr(runtime, "_resolve_command_path", lambda: "/tmp/clawteam")
+
+ status = runtime.get_runtime_status()
- assert "escapeHtml(m.name)" in html
- assert "escapeHtml(m.agentType || 'Agent')" in html
- assert "escapeHtml(m.fromLabel || m.from || 'SYS')" in html
- assert "escapeHtml(m.toLabel || m.to || 'ALL')" in html
- assert "escapeHtml(t.owner || 'Unassigned')" in html
- assert "t.blockedBy.map(v => escapeHtml(v)).join(', ')" in html
- assert "option.textContent =" in html
- assert "document.getElementById('ui-meta').innerText =" in html
- assert "`${t.name || ''}${t.description ? ` - ${t.description}` : ''}`" in html
+ assert status["installed"] is True
+ assert status["current_version"] == "0.3.0"
+ assert status["latest_version"] == "0.4.0"
+ assert status["upgrade_available"] is True
diff --git a/tests/test_leader_watcher.py b/tests/test_leader_watcher.py
new file mode 100644
index 00000000..59ef3bef
--- /dev/null
+++ b/tests/test_leader_watcher.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+import time
+from pathlib import Path
+
+from clawteam.team.leader_watcher import LeaderWatcher
+from clawteam.team.manager import TeamManager
+from clawteam.team.models import TaskStatus
+from clawteam.team.tasks import TaskStore
+
+
+class FakeBackend:
+ def __init__(self):
+ self.injected = []
+
+ def inject_runtime_message(self, team, agent_name, envelope):
+ self.injected.append((team, agent_name, envelope))
+ return True, "injected"
+
+
+def _create_team(tmp_path: Path, monkeypatch, team_name: str = "demo") -> None:
+ monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path))
+ TeamManager.create_team(team_name, leader_name="leader", leader_id="leader-id")
+ TeamManager.add_member(team_name, "worker1", agent_id="worker-id")
+
+
+def test_leader_watcher_injects_startup_and_dedupes(monkeypatch, tmp_path):
+ _create_team(tmp_path, monkeypatch)
+ backend = FakeBackend()
+ monkeypatch.setattr("clawteam.spawn.get_backend", lambda _: backend)
+
+ watcher = LeaderWatcher(
+ "demo",
+ "leader",
+ redis_mode="off",
+ heartbeat_interval=3600,
+ )
+
+ first = watcher.check_once(reason="startup")
+ second = watcher.check_once(reason="poll")
+
+ assert first.injected is True
+ assert second.injected is False
+ assert len(backend.injected) == 1
+ assert "Scheduler check:" in backend.injected[0][2].summary
+
+
+def test_leader_watcher_reinjects_on_task_completion(monkeypatch, tmp_path):
+ _create_team(tmp_path, monkeypatch)
+ backend = FakeBackend()
+ monkeypatch.setattr("clawteam.spawn.get_backend", lambda _: backend)
+ store = TaskStore("demo")
+ task = store.create("Implement feature", owner="worker1")
+
+ watcher = LeaderWatcher(
+ "demo",
+ "leader",
+ redis_mode="off",
+ heartbeat_interval=3600,
+ )
+ watcher.check_once(reason="startup")
+
+ store.update(task.id, status=TaskStatus.completed, caller="worker1", force=True)
+ result = watcher.check_once(reason="poll")
+
+ assert result.injected is True
+ assert len(backend.injected) == 2
+ assert "worker1 finished 1 task(s)" in backend.injected[-1][2].summary
+
+
+def test_leader_watcher_heartbeat_injects_without_state_change(monkeypatch, tmp_path):
+ _create_team(tmp_path, monkeypatch)
+ backend = FakeBackend()
+ monkeypatch.setattr("clawteam.spawn.get_backend", lambda _: backend)
+
+ watcher = LeaderWatcher(
+ "demo",
+ "leader",
+ redis_mode="off",
+ heartbeat_interval=1,
+ )
+ watcher.check_once(reason="startup")
+ time.sleep(1.1)
+ result = watcher.check_once(reason="poll")
+
+ assert result.injected is True
+ assert result.reason == "heartbeat"
+ assert len(backend.injected) == 2
+
+
+def test_redis_wakeup_off_mode(monkeypatch, tmp_path):
+ monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path))
+ from clawteam.team.redis_wakeup import resolve_wakeup
+
+ resolved = resolve_wakeup("demo", "off")
+
+ assert resolved.enabled is False
+ assert resolved.reason == "disabled"
diff --git a/tests/test_session_capture.py b/tests/test_session_capture.py
new file mode 100644
index 00000000..263a6732
--- /dev/null
+++ b/tests/test_session_capture.py
@@ -0,0 +1,223 @@
+from __future__ import annotations
+
+import json
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+
+from clawteam.spawn.session_capture import (
+ build_resume_command,
+ discover_codex_session,
+ prepare_session_capture,
+ persist_spawned_session,
+ save_current_agent_session,
+)
+from clawteam.spawn.session_locators import SessionContext, locator_for_client
+from clawteam.spawn.sessions import SessionStore
+
+
+def test_prepare_session_capture_generates_claude_session_id(monkeypatch, tmp_path):
+ monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path / ".clawteam"))
+
+ capture = prepare_session_capture(
+ ["claude"],
+ team_name="demo",
+ agent_name="leader",
+ cwd=str(tmp_path),
+ )
+
+ assert capture.client == "claude"
+ assert capture.session_id
+ assert capture.command == ["claude", "--session-id", capture.session_id]
+
+ saved = persist_spawned_session(capture, command=capture.command)
+ session = SessionStore("demo").load("leader")
+
+ assert saved == capture.session_id
+ assert session is not None
+ assert session.session_id == capture.session_id
+ assert session.state["client"] == "claude"
+ assert session.state["confidence"] == "exact"
+
+
+def test_prepare_session_capture_keeps_existing_claude_session_id():
+ capture = prepare_session_capture(
+ ["claude", "--session-id", "11111111-1111-4111-8111-111111111111"],
+ team_name="demo",
+ agent_name="worker",
+ )
+
+ assert capture.session_id == "11111111-1111-4111-8111-111111111111"
+ assert capture.source == "provided"
+ assert capture.command == ["claude", "--session-id", "11111111-1111-4111-8111-111111111111"]
+
+
+def test_build_resume_command_supports_codex_and_claude():
+ assert build_resume_command(["claude"], "sess-1") == ["claude", "--resume", "sess-1"]
+ assert build_resume_command(["codex"], "sess-2") == ["codex", "resume", "sess-2"]
+ assert build_resume_command(["run"], "sess-3", client="codex") == ["codex", "resume", "sess-3"]
+
+
+def test_save_current_agent_session_uses_codex_thread_id(monkeypatch, tmp_path):
+ monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path / ".clawteam"))
+ monkeypatch.setenv("CODEX_THREAD_ID", "019dd264-7ba2-7be2-8493-329b1c5ef1f3")
+
+ saved = save_current_agent_session("demo", "leader", cwd=str(tmp_path))
+ session = SessionStore("demo").load("leader")
+
+ assert saved == "019dd264-7ba2-7be2-8493-329b1c5ef1f3"
+ assert session is not None
+ assert session.session_id == saved
+ assert session.state["client"] == "codex"
+
+
+def test_discover_codex_session_matches_recent_agent_prompt(monkeypatch, tmp_path):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ cwd = tmp_path / "repo"
+ cwd.mkdir()
+ session_id = "019dd264-7ba2-7be2-8493-329b1c5ef1f3"
+ session_dir = tmp_path / ".codex" / "sessions" / "2026" / "04" / "28"
+ session_dir.mkdir(parents=True)
+ session_file = session_dir / f"rollout-2026-04-28T12-41-33-{session_id}.jsonl"
+ now = time.time()
+ session_file.write_text(
+ "\n".join(
+ [
+ json.dumps(
+ {
+ "type": "session_meta",
+ "payload": {
+ "id": session_id,
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
+ "cwd": str(cwd),
+ },
+ }
+ ),
+ json.dumps({"type": "user_message", "text": "team demo agent worker1"}),
+ ]
+ ),
+ encoding="utf-8",
+ )
+ Path(session_file).touch()
+
+ found = discover_codex_session(
+ team_name="demo",
+ agent_name="worker1",
+ cwd=str(cwd),
+ since=now - 30,
+ timeout_seconds=0,
+ )
+
+ assert found == session_id
+
+
+def test_spawned_codex_capture_ignores_parent_thread_id(monkeypatch, tmp_path):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path / ".clawteam"))
+ monkeypatch.setenv("CODEX_THREAD_ID", "parent-session")
+ cwd = tmp_path / "repo"
+ cwd.mkdir()
+ child_session_id = "019dd264-7ba2-7be2-8493-329b1c5ef1f3"
+ session_dir = tmp_path / ".codex" / "sessions" / "2026" / "04" / "28"
+ session_dir.mkdir(parents=True)
+ session_file = session_dir / f"rollout-2026-04-28T12-41-33-{child_session_id}.jsonl"
+ now = time.time()
+ session_file.write_text(
+ "\n".join(
+ [
+ json.dumps(
+ {
+ "type": "session_meta",
+ "payload": {
+ "id": child_session_id,
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
+ "cwd": str(cwd),
+ },
+ }
+ ),
+ json.dumps({"type": "user_message", "text": "demo worker1"}),
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+ capture = prepare_session_capture(
+ ["codex"],
+ team_name="demo",
+ agent_name="worker1",
+ cwd=str(cwd),
+ prompt="work for demo worker1",
+ )
+ capture.async_capture = False
+ capture.started_at = now - 30
+ saved = persist_spawned_session(capture, team_name="demo", agent_name="worker1")
+ session = SessionStore("demo").load("worker1")
+
+ assert saved == child_session_id
+ assert session is not None
+ assert session.session_id == child_session_id
+
+
+def test_gemini_locator_reads_workspace_chat_session(monkeypatch, tmp_path):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ cwd = tmp_path / "repo"
+ cwd.mkdir()
+ project_dir = tmp_path / ".gemini" / "tmp" / "project-a"
+ chats_dir = project_dir / "chats"
+ chats_dir.mkdir(parents=True)
+ (project_dir / ".project_root").write_text(str(cwd), encoding="utf-8")
+ (chats_dir / "session.json").write_text(
+ json.dumps(
+ {
+ "sessionId": "gemini-session-1",
+ "lastUpdated": datetime.now(timezone.utc).isoformat(),
+ "messages": [],
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ captured = locator_for_client("gemini").current_session( # type: ignore[union-attr]
+ SessionContext(
+ team_name="demo",
+ agent_name="gem",
+ cwd=str(cwd),
+ allow_environment=False,
+ )
+ )
+
+ assert captured is not None
+ assert captured.session_id == "gemini-session-1"
+
+
+def test_opencode_locator_uses_session_list(monkeypatch, tmp_path):
+ cwd = tmp_path / "repo"
+ cwd.mkdir()
+
+ monkeypatch.setattr("clawteam.spawn.session_locators.opencode.shutil.which", lambda name: "/usr/bin/opencode")
+
+ class Result:
+ returncode = 0
+ stdout = json.dumps(
+ [
+ {"id": "other", "directory": str(tmp_path / "other"), "updated": 3},
+ {"id": "opencode-session-1", "directory": str(cwd), "updated": 5},
+ ]
+ )
+
+ monkeypatch.setattr(
+ "clawteam.spawn.session_locators.opencode.subprocess.run",
+ lambda *_, **__: Result(),
+ )
+
+ captured = locator_for_client("opencode").current_session( # type: ignore[union-attr]
+ SessionContext(
+ team_name="demo",
+ agent_name="open",
+ cwd=str(cwd),
+ allow_environment=False,
+ )
+ )
+
+ assert captured is not None
+ assert captured.session_id == "opencode-session-1"