...
+ result_blocks = re.findall(
+ r']*class="[^"]*result|$)',
+ body,
+ flags=re.DOTALL,
+ )
+
+ entries: list[str] = []
+ for block in result_blocks:
+ if len(entries) >= num_results:
+ break
+
+ # Title + URL — handle both class-before-href and href-before-class
+ link_match = re.search(
+ r'
]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)',
+ block,
+ flags=re.DOTALL,
+ )
+ if not link_match:
+ # Try reversed attribute order
+ link_match = re.search(
+ r'
]*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)',
+ block,
+ flags=re.DOTALL,
+ )
+ if not link_match:
+ continue
+
+ raw_url = html.unescape(link_match.group(1))
+ title = _strip_html(link_match.group(2)).strip()
+
+ # Snippet
+ snippet_match = re.search(
+ r'
]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)',
+ block,
+ flags=re.DOTALL,
+ )
+ snippet = _strip_html(snippet_match.group(1)).strip() if snippet_match else ""
+
+ # DuckDuckGo wraps URLs through a redirect; try to extract the real URL
+ real_url_match = re.search(r"uddg=([^&]+)", raw_url)
+ if real_url_match:
+ from urllib.parse import unquote
+ url = unquote(real_url_match.group(1))
+ else:
+ url = raw_url
+
+ entry = f"[{len(entries) + 1}] {title}\n {url}"
+ if snippet:
+ entry += f"\n {snippet}"
+ entries.append(entry)
+
+ return "\n\n".join(entries)
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# WebFetchTool
+# ───────────────────────────────────────────────────────────────────────────
+
+
+class WebFetchTool(BaseTool):
+ name = "WebFetch"
+ description = (
+ "Fetch the contents of a URL and return the extracted text. "
+ "HTML is stripped to plain text. Output is truncated to ~100 KB."
+ )
+
+ def get_schema(self) -> dict:
+ return {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "The URL to fetch.",
+ },
+ "prompt": {
+ "type": "string",
+ "description": "Optional prompt/context describing what information to look for.",
+ },
+ },
+ "required": ["url"],
+ "additionalProperties": False,
+ }
+
+ async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
+ url: str = input_data["url"]
+ prompt: str | None = input_data.get("prompt")
+
+ try:
+ async with httpx.AsyncClient(
+ timeout=_HTTP_TIMEOUT,
+ follow_redirects=True,
+ headers={"User-Agent": _USER_AGENT},
+ ) as client:
+ resp = await client.get(url)
+ resp.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ return [{"type": "text", "text": f"HTTP error {exc.response.status_code} fetching {url}"}]
+ except Exception as exc:
+ return [{"type": "text", "text": f"Error fetching {url}: {exc}"}]
+
+ content_type = resp.headers.get("content-type", "")
+
+ if "html" in content_type or resp.text.strip().startswith(" dict:
"""Send an approval request and wait for the user's response.
- Returns the approval decision dict."""
+ Returns the approval decision dict. Times out after *timeout* seconds
+ (default 10 minutes) to prevent permanently stuck agents."""
future = asyncio.get_event_loop().create_future()
self.pending_futures[request_id] = future
@@ -75,8 +77,11 @@ class ConnectionManager:
})
try:
- result = await future
+ result = await asyncio.wait_for(future, timeout=timeout)
return result
+ except asyncio.TimeoutError:
+ logger.warning("Approval %s for session %s timed out after %ss", request_id, session_id, timeout)
+ return {"behavior": "deny", "message": "Approval timed out"}
finally:
self.pending_futures.pop(request_id, None)
diff --git a/backend/apps/analytics/__init__.py b/backend/apps/analytics/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/analytics/analytics.py b/backend/apps/analytics/analytics.py
new file mode 100644
index 00000000..600ccbcd
--- /dev/null
+++ b/backend/apps/analytics/analytics.py
@@ -0,0 +1,168 @@
+"""Analytics SubApp: PostHog for product analytics + local usage summary from session data."""
+
+import json
+import logging
+import os
+import platform
+from collections import Counter
+from contextlib import asynccontextmanager
+
+from backend.config.Apps import SubApp
+from backend.config.paths import SESSIONS_DIR
+from backend.apps.analytics.collector import init as init_collector, shutdown as shutdown_collector, record, identify
+
+logger = logging.getLogger(__name__)
+
+
+@asynccontextmanager
+async def analytics_lifespan():
+ init_collector()
+ logger.info("PostHog analytics initialised")
+
+ try:
+ from backend.apps.settings.settings import load_settings
+ settings = load_settings()
+
+ providers = []
+ if getattr(settings, "anthropic_api_key", None):
+ providers.append("anthropic")
+ if getattr(settings, "openai_api_key", None):
+ providers.append("openai")
+ if getattr(settings, "google_api_key", None):
+ providers.append("gemini")
+ if getattr(settings, "openrouter_api_key", None):
+ providers.append("openrouter")
+ for cp in getattr(settings, "custom_providers", []):
+ providers.append(cp.name)
+
+ record("app.opened", {
+ "os": platform.system(),
+ "platform": platform.platform(),
+ "provider_count": len(providers),
+ "providers": providers,
+ "connection_mode": getattr(settings, "connection_mode", "own_key"),
+ })
+
+ identify({
+ "providers_configured": providers,
+ "provider_count": len(providers),
+ "connection_mode": getattr(settings, "connection_mode", "own_key"),
+ })
+ except Exception as e:
+ logger.debug(f"Analytics startup event failed (non-critical): {e}")
+
+ # Auto-start 9Router for subscription access
+ try:
+ from backend.apps.nine_router import ensure_running as ensure_9router, stop as stop_9router
+ await ensure_9router()
+ except Exception as e:
+ logger.debug(f"9Router auto-start skipped: {e}")
+
+ yield
+
+ # Stop 9Router
+ try:
+ from backend.apps.nine_router import stop as stop_9router
+ stop_9router()
+ except Exception:
+ pass
+
+ shutdown_collector()
+ logger.info("PostHog analytics shut down")
+
+
+analytics = SubApp("analytics", analytics_lifespan)
+
+
+def _load_all_sessions() -> list[dict]:
+ """Load all persisted session JSON files."""
+ results = []
+ if not os.path.exists(SESSIONS_DIR):
+ return results
+ for fname in os.listdir(SESSIONS_DIR):
+ if fname.endswith(".json"):
+ try:
+ with open(os.path.join(SESSIONS_DIR, fname)) as f:
+ results.append(json.load(f))
+ except Exception:
+ pass
+ return results
+
+
+@analytics.router.get("/usage-summary")
+async def usage_summary():
+ """Compute usage stats from persisted sessions for the Settings page."""
+ from backend.apps.agents.agent_manager import agent_manager
+
+ # Combine persisted + active sessions
+ sessions = _load_all_sessions()
+ for s in agent_manager.get_all_sessions():
+ sessions.append(s.model_dump(mode="json"))
+
+ total_sessions = len(sessions)
+ total_cost = sum(s.get("cost_usd", 0) for s in sessions)
+ total_messages = 0
+ total_tool_calls = 0
+ total_duration = 0.0
+ model_counts: Counter = Counter()
+ provider_counts: Counter = Counter()
+ tool_counts: Counter = Counter()
+ status_counts: Counter = Counter()
+
+ for s in sessions:
+ messages = s.get("messages", [])
+ user_msgs = [m for m in messages if m.get("role") in ("user", "assistant")]
+ tool_msgs = [m for m in messages if m.get("role") == "tool_call"]
+ total_messages += len(user_msgs)
+ total_tool_calls += len(tool_msgs)
+
+ model_counts[s.get("model", "unknown")] += 1
+ provider_counts[s.get("provider", "anthropic")] += 1
+ status_counts[s.get("status", "unknown")] += 1
+
+ # Duration
+ created = s.get("created_at")
+ closed = s.get("closed_at")
+ if created and closed:
+ try:
+ from datetime import datetime
+ fmt = "%Y-%m-%dT%H:%M:%S"
+ c_str = created[:19]
+ cl_str = closed[:19]
+ dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
+ if dur > 0:
+ total_duration += dur
+ except Exception:
+ pass
+
+ # Count individual tools
+ for m in tool_msgs:
+ content = m.get("content", {})
+ if isinstance(content, dict):
+ tool_name = content.get("tool", "")
+ if tool_name:
+ tool_counts[tool_name] += 1
+
+ avg_duration = total_duration / total_sessions if total_sessions > 0 else 0
+ avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
+ completed = status_counts.get("completed", 0)
+ completion_rate = completed / total_sessions if total_sessions > 0 else 0
+
+ return {
+ "total_sessions": total_sessions,
+ "total_cost_usd": round(total_cost, 4),
+ "total_messages": total_messages,
+ "total_tool_calls": total_tool_calls,
+ "avg_duration_seconds": round(avg_duration, 1),
+ "avg_cost_per_session": round(avg_cost, 4),
+ "completion_rate": round(completion_rate, 3),
+ "models_used": dict(model_counts.most_common(10)),
+ "providers_used": dict(provider_counts.most_common(10)),
+ "top_tools": dict(tool_counts.most_common(15)),
+ "status_breakdown": dict(status_counts),
+ }
+
+
+@analytics.router.get("/status")
+async def analytics_status():
+ return {"status": "posthog", "enabled": True}
diff --git a/backend/apps/analytics/collector.py b/backend/apps/analytics/collector.py
new file mode 100644
index 00000000..dddcedc5
--- /dev/null
+++ b/backend/apps/analytics/collector.py
@@ -0,0 +1,123 @@
+"""PostHog-only analytics collector.
+
+All events go directly to PostHog. No local SQLite storage.
+
+Usage from any module:
+ from backend.apps.analytics.collector import record
+ record("session.started", {"model": "opus"}, session_id="abc123")
+"""
+
+import logging
+import platform
+from uuid import uuid4
+
+from posthog import Posthog
+
+logger = logging.getLogger(__name__)
+
+POSTHOG_API_KEY = "phc_KdVLvAdjCuHeacFoDm1CM1Gb23XikewRqlX67Mj6TNB"
+POSTHOG_HOST = "https://us.i.posthog.com"
+
+_posthog: Posthog | None = None
+_installation_id: str | None = None
+
+
+def init():
+ """Initialise PostHog. Called once at app startup."""
+ global _posthog
+ if _posthog is None:
+ _posthog = Posthog(
+ project_api_key=POSTHOG_API_KEY,
+ host=POSTHOG_HOST,
+ )
+ return _posthog
+
+
+def shutdown():
+ """Flush and close. Called at app shutdown."""
+ global _posthog
+ if _posthog:
+ try:
+ _posthog.shutdown()
+ except Exception:
+ pass
+ _posthog = None
+
+
+def _get_installation_id() -> str:
+ """Get or create a stable anonymous installation ID."""
+ global _installation_id
+ if _installation_id:
+ return _installation_id
+ try:
+ from backend.apps.settings.settings import load_settings, _save_settings
+ settings = load_settings()
+ iid = getattr(settings, "installation_id", None)
+ if not iid:
+ iid = uuid4().hex
+ settings.installation_id = iid
+ _save_settings(settings)
+ _installation_id = iid
+ except Exception:
+ _installation_id = uuid4().hex
+ return _installation_id
+
+
+def _is_opted_in() -> bool:
+ """Check if user has opted in to analytics."""
+ try:
+ from backend.apps.settings.settings import load_settings
+ return getattr(load_settings(), "analytics_opt_in", True)
+ except Exception:
+ return True
+
+
+def record(
+ event_type: str,
+ properties: dict | None = None,
+ session_id: str | None = None,
+ dashboard_id: str | None = None,
+):
+ """Record an analytics event to PostHog."""
+ if not _posthog or not _is_opted_in():
+ return
+
+ props = {**(properties or {})}
+ if session_id:
+ props["session_id"] = session_id
+ if dashboard_id:
+ props["dashboard_id"] = dashboard_id
+ props["os"] = platform.system()
+ props["platform"] = platform.platform()
+
+ try:
+ _posthog.capture(
+ event_type,
+ distinct_id=_get_installation_id(),
+ properties=props,
+ )
+ except Exception as e:
+ logger.debug(f"PostHog capture failed (non-critical): {e}")
+
+
+def identify(extra_properties: dict | None = None):
+ """Identify the current installation with properties."""
+ if not _posthog or not _is_opted_in():
+ return
+
+ try:
+ _posthog.identify(
+ _get_installation_id(),
+ properties={
+ "os": platform.system(),
+ "platform": platform.platform(),
+ **(extra_properties or {}),
+ },
+ )
+ except Exception as e:
+ logger.debug(f"PostHog identify failed (non-critical): {e}")
+
+
+def get_collector():
+ """Backward compat — returns None since we no longer have a local collector."""
+ return None
diff --git a/backend/apps/analytics/models.py b/backend/apps/analytics/models.py
new file mode 100644
index 00000000..a8696dde
--- /dev/null
+++ b/backend/apps/analytics/models.py
@@ -0,0 +1,37 @@
+from pydantic import BaseModel
+from typing import Optional
+
+
+class AnalyticsEvent(BaseModel):
+ id: Optional[int] = None
+ timestamp: str
+ event_type: str
+ properties: dict
+ session_id: Optional[str] = None
+ dashboard_id: Optional[str] = None
+
+
+class UsageSummary(BaseModel):
+ total_sessions: int = 0
+ total_cost_usd: float = 0.0
+ total_messages: int = 0
+ total_tool_calls: int = 0
+ avg_session_duration_seconds: float = 0.0
+ session_completion_rate: float = 0.0
+ approval_rate: float = 0.0
+ models_used: dict[str, int] = {}
+ modes_used: dict[str, int] = {}
+ top_tools: list[list] = []
+
+
+class TimeSeriesPoint(BaseModel):
+ date: str
+ value: float
+
+
+class ExportPayload(BaseModel):
+ export_version: str = "1.0"
+ exported_at: str = ""
+ app_version: str = "unknown"
+ period: dict = {}
+ summary: dict = {}
diff --git a/backend/apps/auth/__init__.py b/backend/apps/auth/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/auth/auth.py b/backend/apps/auth/auth.py
new file mode 100644
index 00000000..1110fa02
--- /dev/null
+++ b/backend/apps/auth/auth.py
@@ -0,0 +1,168 @@
+"""Auth SubApp — handles managed-mode authentication with Open Swarm service.
+
+All endpoints are currently stubbed with mock responses so the full UI flow
+works end-to-end without a real proxy server.
+"""
+
+from contextlib import asynccontextmanager
+from uuid import uuid4
+
+from pydantic import BaseModel
+from typing import Optional
+
+from backend.config.Apps import SubApp
+from backend.apps.settings.settings import load_settings, update_settings
+
+
+# ── Models ──────────────────────────────────────────────────────────────
+
+class LoginRequest(BaseModel):
+ email: str
+ password: str
+
+
+class GoogleCallbackRequest(BaseModel):
+ code: str
+ redirect_uri: str = ""
+
+
+class LoginResponse(BaseModel):
+ ok: bool
+ token: Optional[str] = None
+ email: Optional[str] = None
+ proxy_url: Optional[str] = None
+ error: Optional[str] = None
+
+
+class ValidateResponse(BaseModel):
+ valid: bool
+ email: Optional[str] = None
+
+
+class UsageResponse(BaseModel):
+ used_usd: float
+ quota_usd: float
+ reset_date: str
+
+
+# ── SubApp setup ────────────────────────────────────────────────────────
+
+@asynccontextmanager
+async def _lifespan():
+ yield
+
+auth = SubApp("auth", _lifespan)
+router = auth.router
+
+
+# ── Endpoints ───────────────────────────────────────────────────────────
+
+@router.post("/login")
+async def login(req: LoginRequest) -> LoginResponse:
+ """Authenticate with email + password.
+
+ TODO: replace with real API call to Open Swarm auth server.
+ """
+ if not req.email or not req.password:
+ return LoginResponse(ok=False, error="Email and password are required")
+
+ # Stub: generate a mock token for any valid-looking input
+ mock_token = f"osw_{uuid4().hex}"
+ proxy_url = "https://api.openswarm.ai"
+
+ # Persist credentials to settings
+ settings = load_settings()
+ settings.connection_mode = "managed"
+ settings.openswarm_auth_token = mock_token
+ settings.openswarm_proxy_url = proxy_url
+ settings.openswarm_user_email = req.email
+ await _save_settings(settings)
+
+ return LoginResponse(ok=True, token=mock_token, email=req.email, proxy_url=proxy_url)
+
+
+@router.post("/google-url")
+async def google_auth_url() -> dict:
+ """Return the Google OAuth authorize URL.
+
+ TODO: replace with real Google OAuth URL construction.
+ """
+ # Stub: return a placeholder URL
+ return {
+ "url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=PLACEHOLDER&response_type=code&scope=email+profile&redirect_uri=http://localhost:8324/api/auth/google-callback"
+ }
+
+
+@router.post("/google-callback")
+async def google_callback(req: GoogleCallbackRequest) -> LoginResponse:
+ """Exchange Google OAuth code for a session token.
+
+ TODO: replace with real OAuth code exchange + Open Swarm auth server call.
+ """
+ if not req.code:
+ return LoginResponse(ok=False, error="Authorization code is required")
+
+ # Stub: generate a mock token
+ mock_token = f"osw_{uuid4().hex}"
+ proxy_url = "https://api.openswarm.ai"
+ mock_email = "user@gmail.com"
+
+ settings = load_settings()
+ settings.connection_mode = "managed"
+ settings.openswarm_auth_token = mock_token
+ settings.openswarm_proxy_url = proxy_url
+ settings.openswarm_user_email = mock_email
+ await _save_settings(settings)
+
+ return LoginResponse(ok=True, token=mock_token, email=mock_email, proxy_url=proxy_url)
+
+
+@router.post("/validate")
+async def validate_token() -> ValidateResponse:
+ """Check if the stored auth token is still valid.
+
+ TODO: replace with real validation call to Open Swarm auth server.
+ """
+ settings = load_settings()
+ if not settings.openswarm_auth_token:
+ return ValidateResponse(valid=False)
+
+ # Stub: always return valid
+ return ValidateResponse(valid=True, email=settings.openswarm_user_email)
+
+
+@router.post("/logout")
+async def logout() -> dict:
+ """Clear managed-mode credentials from settings."""
+ settings = load_settings()
+ settings.connection_mode = "own_key"
+ settings.openswarm_auth_token = None
+ settings.openswarm_proxy_url = None
+ settings.openswarm_user_email = None
+ await _save_settings(settings)
+ return {"ok": True}
+
+
+@router.get("/usage")
+async def get_usage() -> UsageResponse:
+ """Fetch usage and quota information for the current managed-mode user.
+
+ TODO: replace with real API call to Open Swarm proxy server.
+ """
+ settings = load_settings()
+ if not settings.openswarm_auth_token:
+ return UsageResponse(used_usd=0, quota_usd=0, reset_date="")
+
+ # Stub: return mock usage data
+ return UsageResponse(used_usd=0, quota_usd=50, reset_date="2026-04-01")
+
+
+# ── Helpers ─────────────────────────────────────────────────────────────
+
+async def _save_settings(settings):
+ """Persist settings to disk (reuses the settings module's update logic)."""
+ import json
+ from backend.apps.settings.settings import SETTINGS_FILE
+
+ with open(SETTINGS_FILE, "w") as f:
+ json.dump(settings.model_dump(), f, indent=2)
diff --git a/backend/apps/channels/__init__.py b/backend/apps/channels/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/channels/adapters/__init__.py b/backend/apps/channels/adapters/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/channels/adapters/telnyx_adapter.py b/backend/apps/channels/adapters/telnyx_adapter.py
new file mode 100644
index 00000000..e5cf037f
--- /dev/null
+++ b/backend/apps/channels/adapters/telnyx_adapter.py
@@ -0,0 +1,146 @@
+"""Telnyx implementation of BaseChannelAdapter.
+
+Uses Telnyx Call Control v2 for voice and Messaging API for SMS.
+"""
+import asyncio
+import hashlib
+import hmac
+import logging
+from typing import Optional
+from functools import partial
+
+from backend.apps.channels.base_adapter import BaseChannelAdapter
+
+logger = logging.getLogger(__name__)
+
+
+class TelnyxAdapter(BaseChannelAdapter):
+
+ def __init__(self, api_key: str, public_key: str = ""):
+ self._api_key = api_key
+ self._public_key = public_key
+ self._telnyx = None
+
+ def _get_telnyx(self):
+ if self._telnyx is None:
+ import telnyx
+ telnyx.api_key = self._api_key
+ self._telnyx = telnyx
+ return self._telnyx
+
+ async def send_sms(self, to: str, from_: str, body: str) -> dict:
+ telnyx = self._get_telnyx()
+ loop = asyncio.get_event_loop()
+ msg = await loop.run_in_executor(
+ None,
+ partial(
+ telnyx.Message.create,
+ to=to,
+ from_=from_,
+ text=body,
+ ),
+ )
+ return {"id": msg.id, "status": getattr(msg, "status", "queued")}
+
+ async def send_whatsapp(self, to: str, from_: str, body: str) -> dict:
+ # Telnyx WhatsApp uses the same messaging API with messaging_profile_id
+ return await self.send_sms(to, from_, body)
+
+ async def initiate_call(
+ self, to: str, from_: str, webhook_url: str, greeting: str = ""
+ ) -> dict:
+ telnyx = self._get_telnyx()
+ loop = asyncio.get_event_loop()
+ call = await loop.run_in_executor(
+ None,
+ partial(
+ telnyx.Call.create,
+ to=to,
+ from_=from_,
+ connection_id=self._api_key, # connection_id should be set separately
+ webhook_url=webhook_url,
+ ),
+ )
+ return {"call_control_id": call.call_control_id, "status": "initiated"}
+
+ def verify_webhook_signature(
+ self, request_url: str, params: dict, signature: str, auth_token: str
+ ) -> bool:
+ if not self._public_key:
+ # Fail closed: no public key means reject
+ logger.error("Telnyx public key not configured — rejecting webhook")
+ return False
+ try:
+ # Telnyx uses Ed25519 signature verification
+ # The signature and timestamp are in webhook headers
+ import base64
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+ from cryptography.hazmat.primitives.serialization import load_pem_public_key
+
+ public_key = load_pem_public_key(self._public_key.encode())
+ sig_bytes = base64.b64decode(signature)
+ payload = params.get("_raw_body", "")
+ timestamp = params.get("_timestamp", "")
+ signed_payload = f"{timestamp}|{payload}"
+ public_key.verify(sig_bytes, signed_payload.encode())
+ return True
+ except Exception:
+ logger.exception("Telnyx signature verification failed")
+ return False
+
+ def generate_twiml_gather(
+ self,
+ prompt: str,
+ action_url: str,
+ voice: str = "Polly.Joanna",
+ language: str = "en-US",
+ timeout: int = 10,
+ ) -> str:
+ # Telnyx uses TeXML (Twilio-compatible XML)
+ return (
+ ''
+ "
"
+ f''
+ f'{_escape_xml(prompt)}'
+ ""
+ f'I didn\'t hear anything. Goodbye.'
+ ""
+ )
+
+ def generate_twiml_say(
+ self, text: str, voice: str = "Polly.Joanna", language: str = "en-US"
+ ) -> str:
+ return (
+ ''
+ "
"
+ f'{_escape_xml(text)}'
+ ""
+ )
+
+ def generate_twiml_hangup(self) -> str:
+ return (
+ ''
+ "
"
+ )
+
+ async def get_recording_audio(self, recording_url: str, auth_token: str) -> bytes:
+ import httpx
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ recording_url,
+ headers={"Authorization": f"Bearer {self._api_key}"},
+ follow_redirects=True,
+ )
+ resp.raise_for_status()
+ return resp.content
+
+
+def _escape_xml(text: str) -> str:
+ return (
+ text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """)
+ .replace("'", "'")
+ )
diff --git a/backend/apps/channels/adapters/twilio_adapter.py b/backend/apps/channels/adapters/twilio_adapter.py
new file mode 100644
index 00000000..7246bea1
--- /dev/null
+++ b/backend/apps/channels/adapters/twilio_adapter.py
@@ -0,0 +1,139 @@
+"""Twilio implementation of BaseChannelAdapter.
+
+Handles SMS, WhatsApp, and Voice via the Twilio Python SDK.
+"""
+import asyncio
+import logging
+from typing import Optional
+from functools import partial
+
+from backend.apps.channels.base_adapter import BaseChannelAdapter
+
+logger = logging.getLogger(__name__)
+
+
+class TwilioAdapter(BaseChannelAdapter):
+
+ def __init__(self, account_sid: str, auth_token: str):
+ self._account_sid = account_sid
+ self._auth_token = auth_token
+ self._client = None
+
+ def _get_client(self):
+ if self._client is None:
+ from twilio.rest import Client
+ self._client = Client(self._account_sid, self._auth_token)
+ return self._client
+
+ async def send_sms(self, to: str, from_: str, body: str) -> dict:
+ client = self._get_client()
+ loop = asyncio.get_event_loop()
+ msg = await loop.run_in_executor(
+ None,
+ partial(
+ client.messages.create,
+ to=to,
+ from_=from_,
+ body=body,
+ ),
+ )
+ return {"sid": msg.sid, "status": msg.status}
+
+ async def send_whatsapp(self, to: str, from_: str, body: str) -> dict:
+ wa_to = to if to.startswith("whatsapp:") else f"whatsapp:{to}"
+ wa_from = from_ if from_.startswith("whatsapp:") else f"whatsapp:{from_}"
+ client = self._get_client()
+ loop = asyncio.get_event_loop()
+ msg = await loop.run_in_executor(
+ None,
+ partial(
+ client.messages.create,
+ to=wa_to,
+ from_=wa_from,
+ body=body,
+ ),
+ )
+ return {"sid": msg.sid, "status": msg.status}
+
+ async def initiate_call(
+ self, to: str, from_: str, webhook_url: str, greeting: str = ""
+ ) -> dict:
+ client = self._get_client()
+ loop = asyncio.get_event_loop()
+ call = await loop.run_in_executor(
+ None,
+ partial(
+ client.calls.create,
+ to=to,
+ from_=from_,
+ url=webhook_url,
+ ),
+ )
+ return {"sid": call.sid, "status": call.status}
+
+ def verify_webhook_signature(
+ self, request_url: str, params: dict, signature: str, auth_token: str
+ ) -> bool:
+ try:
+ from twilio.request_validator import RequestValidator
+ validator = RequestValidator(auth_token)
+ return validator.validate(request_url, params, signature)
+ except Exception:
+ logger.exception("Twilio signature verification failed")
+ return False
+
+ def generate_twiml_gather(
+ self,
+ prompt: str,
+ action_url: str,
+ voice: str = "Polly.Joanna",
+ language: str = "en-US",
+ timeout: int = 10,
+ ) -> str:
+ return (
+ ''
+ "
"
+ f''
+ f'{_escape_xml(prompt)}'
+ ""
+ f'I didn\'t hear anything. Goodbye.'
+ ""
+ )
+
+ def generate_twiml_say(
+ self, text: str, voice: str = "Polly.Joanna", language: str = "en-US"
+ ) -> str:
+ return (
+ ''
+ "
"
+ f'{_escape_xml(text)}'
+ ""
+ )
+
+ def generate_twiml_hangup(self) -> str:
+ return (
+ ''
+ "
"
+ )
+
+ async def get_recording_audio(self, recording_url: str, auth_token: str) -> bytes:
+ import httpx
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ recording_url,
+ auth=(self._account_sid, auth_token),
+ follow_redirects=True,
+ )
+ resp.raise_for_status()
+ return resp.content
+
+
+def _escape_xml(text: str) -> str:
+ return (
+ text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """)
+ .replace("'", "'")
+ )
diff --git a/backend/apps/channels/base_adapter.py b/backend/apps/channels/base_adapter.py
new file mode 100644
index 00000000..bafeeac7
--- /dev/null
+++ b/backend/apps/channels/base_adapter.py
@@ -0,0 +1,88 @@
+from abc import ABC, abstractmethod
+from typing import Optional
+
+
+class BaseChannelAdapter(ABC):
+ """Provider-agnostic interface for telephony operations."""
+
+ @abstractmethod
+ async def send_sms(self, to: str, from_: str, body: str) -> dict:
+ """Send an SMS message. Returns provider response dict."""
+ ...
+
+ @abstractmethod
+ async def send_whatsapp(self, to: str, from_: str, body: str) -> dict:
+ """Send a WhatsApp message. Returns provider response dict."""
+ ...
+
+ @abstractmethod
+ async def initiate_call(
+ self, to: str, from_: str, webhook_url: str, greeting: str = ""
+ ) -> dict:
+ """Initiate an outbound voice call. Returns provider response dict."""
+ ...
+
+ @abstractmethod
+ def verify_webhook_signature(
+ self, request_url: str, params: dict, signature: str, auth_token: str
+ ) -> bool:
+ """Verify that an inbound webhook is authentic."""
+ ...
+
+ @abstractmethod
+ def generate_twiml_gather(
+ self,
+ prompt: str,
+ action_url: str,
+ voice: str = "Polly.Joanna",
+ language: str = "en-US",
+ timeout: int = 10,
+ ) -> str:
+ """Generate TwiML (or equivalent) to play a prompt and gather speech."""
+ ...
+
+ @abstractmethod
+ def generate_twiml_say(
+ self, text: str, voice: str = "Polly.Joanna", language: str = "en-US"
+ ) -> str:
+ """Generate TwiML (or equivalent) to speak text."""
+ ...
+
+ @abstractmethod
+ def generate_twiml_hangup(self) -> str:
+ """Generate TwiML (or equivalent) to end a call."""
+ ...
+
+ @abstractmethod
+ async def get_recording_audio(self, recording_url: str, auth_token: str) -> bytes:
+ """Download audio from a recording URL."""
+ ...
+
+ def chunk_message(self, text: str, max_length: int = 1600) -> list[str]:
+ """Split a long message into chunks respecting sentence boundaries."""
+ if len(text) <= max_length:
+ return [text]
+
+ chunks: list[str] = []
+ remaining = text
+
+ while remaining:
+ if len(remaining) <= max_length:
+ chunks.append(remaining)
+ break
+
+ # Try to split at sentence boundary
+ split_at = -1
+ for sep in [". ", "! ", "? ", "\n\n", "\n", " "]:
+ idx = remaining.rfind(sep, 0, max_length)
+ if idx > 0:
+ split_at = idx + len(sep)
+ break
+
+ if split_at <= 0:
+ split_at = max_length
+
+ chunks.append(remaining[:split_at].rstrip())
+ remaining = remaining[split_at:].lstrip()
+
+ return chunks
diff --git a/backend/apps/channels/call_state.py b/backend/apps/channels/call_state.py
new file mode 100644
index 00000000..6a3abc95
--- /dev/null
+++ b/backend/apps/channels/call_state.py
@@ -0,0 +1,129 @@
+import logging
+from datetime import datetime
+from typing import Optional, Literal
+
+logger = logging.getLogger(__name__)
+
+CallStatus = Literal[
+ "ringing", "connected", "gathering", "processing", "responding", "completed", "failed"
+]
+
+VALID_TRANSITIONS: dict[CallStatus, set[CallStatus]] = {
+ "ringing": {"connected", "completed", "failed"},
+ "connected": {"gathering", "completed", "failed"},
+ "gathering": {"processing", "completed", "failed"},
+ "processing": {"responding", "completed", "failed"},
+ "responding": {"gathering", "completed", "failed"},
+ "completed": set(),
+ "failed": set(),
+}
+
+
+class CallState:
+ """Tracks the lifecycle of a single voice call."""
+
+ def __init__(
+ self,
+ call_sid: str,
+ channel_id: str,
+ from_number: str,
+ to_number: str,
+ ):
+ self.call_sid = call_sid
+ self.channel_id = channel_id
+ self.from_number = from_number
+ self.to_number = to_number
+ self.agent_session_id: Optional[str] = None
+ self.status: CallStatus = "ringing"
+ self.turns: list[dict] = []
+ self.created_at = datetime.now()
+ self.last_activity = datetime.now()
+ self.error: Optional[str] = None
+
+ def transition(self, new_status: CallStatus) -> bool:
+ """Attempt a state transition. Returns True if valid."""
+ if new_status in VALID_TRANSITIONS.get(self.status, set()):
+ logger.info(
+ "Call %s: %s -> %s", self.call_sid, self.status, new_status
+ )
+ self.status = new_status
+ self.last_activity = datetime.now()
+ return True
+ logger.warning(
+ "Call %s: invalid transition %s -> %s",
+ self.call_sid, self.status, new_status,
+ )
+ return False
+
+ def add_turn(self, role: str, content: str):
+ self.turns.append({
+ "role": role,
+ "content": content,
+ "timestamp": datetime.now().isoformat(),
+ })
+ self.last_activity = datetime.now()
+
+ @property
+ def is_active(self) -> bool:
+ return self.status not in ("completed", "failed")
+
+ @property
+ def duration_seconds(self) -> float:
+ return (datetime.now() - self.created_at).total_seconds()
+
+ def to_dict(self) -> dict:
+ return {
+ "call_sid": self.call_sid,
+ "channel_id": self.channel_id,
+ "from_number": self.from_number,
+ "to_number": self.to_number,
+ "agent_session_id": self.agent_session_id,
+ "status": self.status,
+ "turns": self.turns,
+ "created_at": self.created_at.isoformat(),
+ "last_activity": self.last_activity.isoformat(),
+ "duration_seconds": self.duration_seconds,
+ "error": self.error,
+ }
+
+
+class CallManager:
+ """Manages all active voice calls."""
+
+ def __init__(self):
+ self.calls: dict[str, CallState] = {}
+
+ def create_call(
+ self,
+ call_sid: str,
+ channel_id: str,
+ from_number: str,
+ to_number: str,
+ ) -> CallState:
+ call = CallState(call_sid, channel_id, from_number, to_number)
+ self.calls[call_sid] = call
+ return call
+
+ def get_call(self, call_sid: str) -> Optional[CallState]:
+ return self.calls.get(call_sid)
+
+ def end_call(self, call_sid: str, status: CallStatus = "completed"):
+ call = self.calls.get(call_sid)
+ if call:
+ call.transition(status)
+
+ def cleanup_stale(self, max_duration_seconds: int = 3600):
+ """Remove calls that have exceeded max duration."""
+ stale = [
+ sid
+ for sid, call in self.calls.items()
+ if not call.is_active or call.duration_seconds > max_duration_seconds
+ ]
+ for sid in stale:
+ if self.calls[sid].is_active:
+ self.calls[sid].transition("failed")
+ self.calls[sid].error = "Exceeded max call duration"
+ del self.calls[sid]
+
+ def get_active_calls(self) -> list[dict]:
+ return [c.to_dict() for c in self.calls.values() if c.is_active]
diff --git a/backend/apps/channels/channels.py b/backend/apps/channels/channels.py
new file mode 100644
index 00000000..60da8283
--- /dev/null
+++ b/backend/apps/channels/channels.py
@@ -0,0 +1,387 @@
+"""Channels SubApp — REST endpoints and Twilio/Telnyx webhooks."""
+import logging
+import os
+from contextlib import asynccontextmanager
+from datetime import datetime
+
+from fastapi import HTTPException, Request
+from fastapi.responses import JSONResponse, Response
+
+from backend.config.Apps import SubApp
+from backend.apps.channels.models import (
+ ChannelConfig, ChannelCreate, ChannelUpdate, VoiceConfig, TTSConfig, STTConfig,
+)
+from backend.apps.channels.orchestrator import channel_orchestrator
+from backend.apps.channels import ws_events
+
+logger = logging.getLogger(__name__)
+
+
+@asynccontextmanager
+async def channels_lifespan():
+ logger.info("Channels sub-app starting")
+ await channel_orchestrator.restore_all()
+ yield
+ logger.info("Channels sub-app shutting down")
+ await channel_orchestrator.persist_all()
+
+
+channels = SubApp("channels", channels_lifespan)
+
+
+# ─── CRUD Endpoints ──────────────────────────────────────────────
+
+
+@channels.router.get("/list")
+async def list_channels():
+ configs = list(channel_orchestrator.configs.values())
+ return {
+ "channels": [c.model_dump(mode="json") for c in configs],
+ }
+
+
+@channels.router.get("/{channel_id}")
+async def get_channel(channel_id: str):
+ config = channel_orchestrator.configs.get(channel_id)
+ if not config:
+ raise HTTPException(404, "Channel not found")
+ return config.model_dump(mode="json")
+
+
+@channels.router.post("/create")
+async def create_channel(body: ChannelCreate):
+ config = ChannelConfig(
+ name=body.name,
+ channel_type=body.channel_type,
+ provider=body.provider,
+ phone_number=body.phone_number,
+ credentials=body.credentials,
+ )
+ if body.agent_config:
+ config.agent_config = body.agent_config
+ if body.security:
+ config.security = body.security
+ if body.voice_config:
+ config.voice_config = body.voice_config
+ if body.tts_config:
+ config.tts_config = body.tts_config
+ if body.stt_config:
+ config.stt_config = body.stt_config
+
+ channel_orchestrator.save_config(config)
+ return {"channel": config.model_dump(mode="json")}
+
+
+@channels.router.put("/{channel_id}")
+async def update_channel(channel_id: str, body: ChannelUpdate):
+ config = channel_orchestrator.configs.get(channel_id)
+ if not config:
+ raise HTTPException(404, "Channel not found")
+
+ updates = body.model_dump(exclude_none=True)
+ for key, val in updates.items():
+ setattr(config, key, val)
+
+ # Re-create adapter if credentials changed
+ if "credentials" in updates or "provider" in updates:
+ channel_orchestrator.adapters.pop(channel_id, None)
+
+ channel_orchestrator.save_config(config)
+ return {"channel": config.model_dump(mode="json")}
+
+
+@channels.router.delete("/{channel_id}")
+async def delete_channel(channel_id: str):
+ if channel_id not in channel_orchestrator.configs:
+ raise HTTPException(404, "Channel not found")
+ channel_orchestrator.delete_config(channel_id)
+ return {"ok": True}
+
+
+# ─── Enable / Disable / Test ─────────────────────────────────────
+
+
+@channels.router.post("/{channel_id}/enable")
+async def enable_channel(channel_id: str):
+ config = channel_orchestrator.configs.get(channel_id)
+ if not config:
+ raise HTTPException(404, "Channel not found")
+
+ try:
+ channel_orchestrator.get_adapter(config)
+ config.enabled = True
+ config.status = "active"
+ config.status_message = None
+ channel_orchestrator.save_config(config)
+ await ws_events.emit_channel_status(channel_id, "active")
+ return {"ok": True, "status": "active"}
+ except Exception as e:
+ config.status = "error"
+ config.status_message = str(e)
+ channel_orchestrator.save_config(config)
+ raise HTTPException(400, f"Failed to enable channel: {e}")
+
+
+@channels.router.post("/{channel_id}/disable")
+async def disable_channel(channel_id: str):
+ config = channel_orchestrator.configs.get(channel_id)
+ if not config:
+ raise HTTPException(404, "Channel not found")
+
+ config.enabled = False
+ config.status = "inactive"
+ channel_orchestrator.adapters.pop(channel_id, None)
+ channel_orchestrator.save_config(config)
+ await ws_events.emit_channel_status(channel_id, "inactive")
+ return {"ok": True}
+
+
+@channels.router.post("/{channel_id}/test")
+async def test_channel(channel_id: str, body: dict | None = None):
+ config = channel_orchestrator.configs.get(channel_id)
+ if not config:
+ raise HTTPException(404, "Channel not found")
+
+ to_number = (body or {}).get("to_number", "")
+ if not to_number:
+ raise HTTPException(400, "to_number is required for test")
+
+ try:
+ adapter = channel_orchestrator.get_adapter(config)
+ if config.channel_type == "whatsapp":
+ result = await adapter.send_whatsapp(to_number, config.phone_number, "Test message from Open Swarm")
+ elif config.channel_type == "voice":
+ result = {"message": "Voice test: configure webhook and call the number"}
+ else:
+ result = await adapter.send_sms(to_number, config.phone_number, "Test message from Open Swarm")
+ return {"ok": True, "result": result}
+ except Exception as e:
+ raise HTTPException(400, f"Test failed: {e}")
+
+
+# ─── Conversations ────────────────────────────────────────────────
+
+
+@channels.router.get("/{channel_id}/conversations")
+async def list_conversations(channel_id: str):
+ convs = [
+ c.model_dump(mode="json")
+ for c in channel_orchestrator.conversations.values()
+ if c.channel_id == channel_id
+ ]
+ return {"conversations": convs}
+
+
+@channels.router.get("/{channel_id}/conversations/{conversation_id}")
+async def get_conversation(channel_id: str, conversation_id: str):
+ for conv in channel_orchestrator.conversations.values():
+ if conv.id == conversation_id and conv.channel_id == channel_id:
+ return conv.model_dump(mode="json")
+ raise HTTPException(404, "Conversation not found")
+
+
+# ─── Outbound ─────────────────────────────────────────────────────
+
+
+@channels.router.post("/{channel_id}/send")
+async def send_outbound(channel_id: str, body: dict):
+ to_number = body.get("to_number", "")
+ message = body.get("message", "")
+ if not to_number or not message:
+ raise HTTPException(400, "to_number and message are required")
+ try:
+ result = await channel_orchestrator.send_outbound(channel_id, to_number, message)
+ return result
+ except ValueError as e:
+ raise HTTPException(404, str(e))
+
+
+@channels.router.post("/{channel_id}/call")
+async def initiate_call(channel_id: str, body: dict):
+ to_number = body.get("to_number", "")
+ if not to_number:
+ raise HTTPException(400, "to_number is required")
+ try:
+ result = await channel_orchestrator.initiate_outbound_call(channel_id, to_number)
+ return result
+ except ValueError as e:
+ raise HTTPException(404, str(e))
+
+
+# ─── Twilio Webhooks ─────────────────────────────────────────────
+
+
+@channels.router.post("/webhooks/twilio/sms")
+async def twilio_sms_webhook(request: Request):
+ """Inbound SMS webhook from Twilio."""
+ form = await request.form()
+ channel_id = request.query_params.get("channel_id", "")
+
+ # Find channel by phone number if channel_id not provided
+ if not channel_id:
+ to_number = form.get("To", "")
+ for cfg in channel_orchestrator.configs.values():
+ if cfg.phone_number == to_number and cfg.channel_type == "sms":
+ channel_id = cfg.id
+ break
+
+ config = channel_orchestrator.configs.get(channel_id)
+ if not config:
+ return Response(status_code=404)
+
+ # Verify signature
+ if config.security.verify_signatures:
+ adapter = channel_orchestrator.get_adapter(config)
+ sig = request.headers.get("X-Twilio-Signature", "")
+ url = str(request.url)
+ if not adapter.verify_webhook_signature(url, dict(form), sig, config.credentials.get("auth_token", "")):
+ logger.warning("Invalid Twilio signature for channel %s", channel_id)
+ return Response(status_code=403)
+
+ from_number = form.get("From", "")
+ body = form.get("Body", "")
+ num_media = int(form.get("NumMedia", "0"))
+ media_urls = [form.get(f"MediaUrl{i}", "") for i in range(num_media)]
+ media_urls = [u for u in media_urls if u]
+
+ await channel_orchestrator.handle_inbound_sms(channel_id, from_number, body, media_urls)
+
+ # Return empty TwiML (Twilio expects XML response)
+ return Response(
+ content='
',
+ media_type="application/xml",
+ )
+
+
+@channels.router.post("/webhooks/twilio/whatsapp")
+async def twilio_whatsapp_webhook(request: Request):
+ """Inbound WhatsApp webhook from Twilio."""
+ form = await request.form()
+ channel_id = request.query_params.get("channel_id", "")
+
+ if not channel_id:
+ to_number = form.get("To", "").replace("whatsapp:", "")
+ for cfg in channel_orchestrator.configs.values():
+ if cfg.phone_number == to_number and cfg.channel_type == "whatsapp":
+ channel_id = cfg.id
+ break
+
+ config = channel_orchestrator.configs.get(channel_id)
+ if not config:
+ return Response(status_code=404)
+
+ if config.security.verify_signatures:
+ adapter = channel_orchestrator.get_adapter(config)
+ sig = request.headers.get("X-Twilio-Signature", "")
+ if not adapter.verify_webhook_signature(str(request.url), dict(form), sig, config.credentials.get("auth_token", "")):
+ return Response(status_code=403)
+
+ from_number = form.get("From", "").replace("whatsapp:", "")
+ body = form.get("Body", "")
+ num_media = int(form.get("NumMedia", "0"))
+ media_urls = [form.get(f"MediaUrl{i}", "") for i in range(num_media)]
+
+ await channel_orchestrator.handle_inbound_sms(channel_id, from_number, body, media_urls or None)
+
+ return Response(
+ content='
',
+ media_type="application/xml",
+ )
+
+
+@channels.router.post("/webhooks/twilio/voice")
+async def twilio_voice_webhook(request: Request):
+ """Inbound voice call webhook from Twilio."""
+ form = await request.form()
+ channel_id = request.query_params.get("channel_id", "")
+
+ if not channel_id:
+ to_number = form.get("To", "")
+ for cfg in channel_orchestrator.configs.values():
+ if cfg.phone_number == to_number and cfg.channel_type == "voice":
+ channel_id = cfg.id
+ break
+
+ call_sid = form.get("CallSid", "")
+ from_number = form.get("From", "")
+ to_number = form.get("To", "")
+
+ twiml = await channel_orchestrator.handle_inbound_call(
+ channel_id, call_sid, from_number, to_number
+ )
+
+ return Response(content=twiml, media_type="application/xml")
+
+
+@channels.router.post("/webhooks/twilio/voice/gather")
+async def twilio_voice_gather_webhook(request: Request):
+ """Speech gathered from a voice call."""
+ form = await request.form()
+ channel_id = request.query_params.get("channel_id", "")
+ call_sid = request.query_params.get("call_sid", "") or form.get("CallSid", "")
+
+ speech_result = form.get("SpeechResult", "")
+
+ if not speech_result:
+ # No speech detected, ask again or hang up
+ config = channel_orchestrator.configs.get(channel_id)
+ if config:
+ adapter = channel_orchestrator.get_adapter(config)
+ voice_cfg = config.voice_config or VoiceConfig()
+ twiml = adapter.generate_twiml_say(
+ "I didn't catch that. Goodbye.", voice=voice_cfg.voice
+ )
+ else:
+ twiml = '
Goodbye.'
+ return Response(content=twiml, media_type="application/xml")
+
+ twiml = await channel_orchestrator.handle_voice_gather(
+ channel_id, call_sid, speech_result
+ )
+
+ return Response(content=twiml, media_type="application/xml")
+
+
+@channels.router.post("/webhooks/twilio/voice/status")
+async def twilio_voice_status_webhook(request: Request):
+ """Call status update from Twilio."""
+ form = await request.form()
+ call_sid = form.get("CallSid", "")
+ status = form.get("CallStatus", "")
+
+ channel_orchestrator.handle_call_status(call_sid, status)
+ return Response(status_code=204)
+
+
+# ─── Telnyx Webhook ───────────────────────────────────────────────
+
+
+@channels.router.post("/webhooks/telnyx")
+async def telnyx_webhook(request: Request):
+ """Unified Telnyx webhook for SMS and Voice events."""
+ body = await request.json()
+ event_type = body.get("data", {}).get("event_type", "")
+ payload = body.get("data", {}).get("payload", {})
+
+ channel_id = request.query_params.get("channel_id", "")
+
+ if event_type == "message.received":
+ from_number = payload.get("from", {}).get("phone_number", "")
+ text = payload.get("text", "")
+ await channel_orchestrator.handle_inbound_sms(channel_id, from_number, text)
+ elif event_type in ("call.initiated", "call.answered"):
+ call_sid = payload.get("call_control_id", "")
+ from_number = payload.get("from", "")
+ to_number = payload.get("to", "")
+ # Telnyx voice uses Call Control commands rather than TwiML
+ logger.info("Telnyx call event: %s for %s", event_type, call_sid)
+
+ return JSONResponse({"ok": True})
+
+
+# ─── Active Calls ─────────────────────────────────────────────────
+
+
+@channels.router.get("/calls/active")
+async def list_active_calls():
+ return {"calls": channel_orchestrator.call_manager.get_active_calls()}
diff --git a/backend/apps/channels/media_handler.py b/backend/apps/channels/media_handler.py
new file mode 100644
index 00000000..71055e4c
--- /dev/null
+++ b/backend/apps/channels/media_handler.py
@@ -0,0 +1,60 @@
+"""Audio attachment processing for WhatsApp voice notes and media messages."""
+import logging
+from typing import Optional
+
+import httpx
+
+from backend.apps.channels.models import STTConfig
+from backend.apps.channels import stt_service
+
+logger = logging.getLogger(__name__)
+
+SUPPORTED_FORMATS = {
+ "audio/ogg", "audio/mpeg", "audio/wav", "audio/mp4",
+ "audio/flac", "audio/webm", "audio/x-wav",
+}
+MAX_MEDIA_BYTES = 20 * 1024 * 1024
+
+
+async def process_audio_attachment(
+ url: str,
+ content_type: str,
+ stt_config: STTConfig,
+ auth: tuple[str, str] | None = None,
+) -> Optional[str]:
+ """Download an audio attachment and return its transcript.
+
+ Args:
+ url: URL to download the audio from.
+ content_type: MIME type of the audio.
+ stt_config: STT configuration for transcription.
+ auth: Optional (username, password) tuple for basic auth (e.g. Twilio).
+
+ Returns:
+ Transcript string, or None on failure.
+ """
+ if content_type not in SUPPORTED_FORMATS:
+ logger.warning("Unsupported audio format: %s", content_type)
+ return None
+
+ try:
+ async with httpx.AsyncClient(timeout=60) as client:
+ kwargs = {"follow_redirects": True}
+ if auth:
+ kwargs["auth"] = auth
+ resp = await client.get(url, **kwargs)
+ resp.raise_for_status()
+ audio_bytes = resp.content
+ except Exception:
+ logger.exception("Failed to download audio from %s", url)
+ return None
+
+ if len(audio_bytes) > MAX_MEDIA_BYTES:
+ logger.warning("Audio attachment exceeds %d bytes", MAX_MEDIA_BYTES)
+ return None
+
+ if len(audio_bytes) < 1024:
+ logger.debug("Audio attachment too small, skipping")
+ return None
+
+ return await stt_service.transcribe(audio_bytes, stt_config, content_type)
diff --git a/backend/apps/channels/models.py b/backend/apps/channels/models.py
new file mode 100644
index 00000000..3c0b9502
--- /dev/null
+++ b/backend/apps/channels/models.py
@@ -0,0 +1,116 @@
+from pydantic import BaseModel, Field
+from typing import Optional, Literal, Any
+from datetime import datetime
+from uuid import uuid4
+
+
+class ChannelAgentConfig(BaseModel):
+ mode: str = "agent"
+ model: str = "sonnet"
+ system_prompt: Optional[str] = None
+ max_turns: int = 10
+ allowed_tools: Optional[list[str]] = None
+
+
+class ChannelSecurityConfig(BaseModel):
+ verify_signatures: bool = True
+ allowlist: list[str] = Field(default_factory=list)
+ blocklist: list[str] = Field(default_factory=list)
+ rate_limit_per_minute: int = 10
+ rate_limit_per_hour: int = 60
+
+
+class VoiceConfig(BaseModel):
+ mode: Literal["conversation", "notify"] = "conversation"
+ greeting_message: str = "Hello, how can I help you?"
+ silence_timeout_ms: int = 700
+ max_call_duration_seconds: int = 600
+ gather_timeout_seconds: int = 10
+ voice: str = "Polly.Joanna"
+ language: str = "en-US"
+
+
+class TTSConfig(BaseModel):
+ provider: Literal["twilio_say", "elevenlabs", "openai_tts", "edge_tts"] = "twilio_say"
+ auto_tts_mode: Literal["off", "always", "inbound", "tagged"] = "off"
+ elevenlabs_voice_id: Optional[str] = None
+ elevenlabs_model_id: str = "eleven_v3"
+ openai_voice: str = "alloy"
+ skip_short_text: bool = True
+ summarize_long_replies: bool = True
+ max_tts_chars: int = 4000
+
+
+class STTConfig(BaseModel):
+ provider: Literal["twilio_builtin", "deepgram", "openai_whisper"] = "twilio_builtin"
+ deepgram_model: str = "nova-3"
+ language: str = "en-US"
+ fallback_chain: list[str] = Field(default_factory=lambda: ["twilio_builtin"])
+
+
+class ChannelConfig(BaseModel):
+ id: str = Field(default_factory=lambda: uuid4().hex)
+ name: str = ""
+ channel_type: Literal["sms", "whatsapp", "voice"] = "sms"
+ provider: Literal["twilio", "telnyx"] = "twilio"
+ enabled: bool = False
+ phone_number: str = ""
+ credentials: dict[str, str] = Field(default_factory=dict)
+ agent_config: ChannelAgentConfig = Field(default_factory=ChannelAgentConfig)
+ security: ChannelSecurityConfig = Field(default_factory=ChannelSecurityConfig)
+ voice_config: Optional[VoiceConfig] = None
+ tts_config: Optional[TTSConfig] = None
+ stt_config: Optional[STTConfig] = None
+ status: Literal["inactive", "active", "error"] = "inactive"
+ status_message: Optional[str] = None
+ created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
+ updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
+ last_message_at: Optional[str] = None
+ message_count: int = 0
+
+
+class ChannelMessage(BaseModel):
+ id: str = Field(default_factory=lambda: uuid4().hex)
+ direction: Literal["inbound", "outbound"] = "inbound"
+ content: str = ""
+ media_urls: list[str] = Field(default_factory=list)
+ timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
+ channel_type: str = ""
+ provider_message_id: Optional[str] = None
+
+
+class ChannelConversation(BaseModel):
+ id: str = Field(default_factory=lambda: uuid4().hex)
+ channel_id: str = ""
+ phone_number: str = ""
+ agent_session_id: Optional[str] = None
+ messages: list[ChannelMessage] = Field(default_factory=list)
+ created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
+ updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
+ status: Literal["active", "closed"] = "active"
+
+
+class ChannelCreate(BaseModel):
+ name: str
+ channel_type: Literal["sms", "whatsapp", "voice"] = "sms"
+ provider: Literal["twilio", "telnyx"] = "twilio"
+ phone_number: str = ""
+ credentials: dict[str, str] = Field(default_factory=dict)
+ agent_config: Optional[ChannelAgentConfig] = None
+ security: Optional[ChannelSecurityConfig] = None
+ voice_config: Optional[VoiceConfig] = None
+ tts_config: Optional[TTSConfig] = None
+ stt_config: Optional[STTConfig] = None
+
+
+class ChannelUpdate(BaseModel):
+ name: Optional[str] = None
+ channel_type: Optional[Literal["sms", "whatsapp", "voice"]] = None
+ provider: Optional[Literal["twilio", "telnyx"]] = None
+ phone_number: Optional[str] = None
+ credentials: Optional[dict[str, str]] = None
+ agent_config: Optional[ChannelAgentConfig] = None
+ security: Optional[ChannelSecurityConfig] = None
+ voice_config: Optional[VoiceConfig] = None
+ tts_config: Optional[TTSConfig] = None
+ stt_config: Optional[STTConfig] = None
diff --git a/backend/apps/channels/orchestrator.py b/backend/apps/channels/orchestrator.py
new file mode 100644
index 00000000..4b06979e
--- /dev/null
+++ b/backend/apps/channels/orchestrator.py
@@ -0,0 +1,559 @@
+"""Channel orchestrator — routes inbound messages/calls to agent sessions.
+
+This is the central routing layer that bridges telephony events to the
+existing AgentManager. Each phone number gets its own ChannelConversation
+which maps to an AgentSession.
+"""
+import asyncio
+import json
+import logging
+import os
+import time
+from datetime import datetime
+from typing import Optional
+
+from backend.apps.channels.models import (
+ ChannelConfig, ChannelConversation, ChannelMessage,
+)
+from backend.apps.channels.call_state import CallManager, CallState
+from backend.apps.channels.base_adapter import BaseChannelAdapter
+from backend.apps.channels import ws_events
+from backend.apps.agents.models import AgentConfig
+from backend.config.paths import DATA_ROOT
+
+logger = logging.getLogger(__name__)
+
+CHANNELS_DIR = os.path.join(DATA_ROOT, "channels")
+CHANNELS_SESSIONS_DIR = os.path.join(DATA_ROOT, "channels", "sessions")
+
+PLATFORM_MAX_LENGTH = {
+ "sms": 1600,
+ "whatsapp": 4096,
+ "voice": 100000,
+}
+
+
+class RateLimiter:
+ """Simple token-bucket rate limiter per phone number."""
+
+ def __init__(self):
+ self._buckets: dict[str, list[float]] = {}
+
+ def check(self, key: str, per_minute: int, per_hour: int) -> bool:
+ now = time.time()
+ if key not in self._buckets:
+ self._buckets[key] = []
+
+ # Prune old entries
+ self._buckets[key] = [t for t in self._buckets[key] if now - t < 3600]
+
+ recent_minute = sum(1 for t in self._buckets[key] if now - t < 60)
+ recent_hour = len(self._buckets[key])
+
+ if recent_minute >= per_minute or recent_hour >= per_hour:
+ return False
+
+ self._buckets[key].append(now)
+ return True
+
+
+class ChannelOrchestrator:
+ """Manages channel configs, conversations, and message routing."""
+
+ def __init__(self):
+ self.configs: dict[str, ChannelConfig] = {}
+ self.conversations: dict[str, ChannelConversation] = {} # key: "{channel_id}:{phone}"
+ self.adapters: dict[str, BaseChannelAdapter] = {}
+ self.call_manager = CallManager()
+ self.rate_limiter = RateLimiter()
+ self._agent_listeners: dict[str, asyncio.Task] = {}
+
+ # ─── Config persistence ───────────────────────────────────────
+
+ def _ensure_dirs(self):
+ os.makedirs(CHANNELS_DIR, exist_ok=True)
+ os.makedirs(CHANNELS_SESSIONS_DIR, exist_ok=True)
+
+ def _config_path(self, channel_id: str) -> str:
+ return os.path.join(CHANNELS_DIR, f"{channel_id}.json")
+
+ def _conv_path(self, channel_id: str) -> str:
+ return os.path.join(CHANNELS_SESSIONS_DIR, f"{channel_id}.json")
+
+ def save_config(self, config: ChannelConfig):
+ self._ensure_dirs()
+ config.updated_at = datetime.now().isoformat()
+ self.configs[config.id] = config
+ with open(self._config_path(config.id), "w") as f:
+ json.dump(config.model_dump(mode="json"), f, indent=2)
+
+ def delete_config(self, channel_id: str):
+ self.configs.pop(channel_id, None)
+ self.adapters.pop(channel_id, None)
+ path = self._config_path(channel_id)
+ if os.path.exists(path):
+ os.remove(path)
+
+ def load_all_configs(self):
+ self._ensure_dirs()
+ self.configs.clear()
+ for fname in os.listdir(CHANNELS_DIR):
+ if fname.endswith(".json"):
+ try:
+ with open(os.path.join(CHANNELS_DIR, fname)) as f:
+ data = json.load(f)
+ config = ChannelConfig(**data)
+ self.configs[config.id] = config
+ except Exception:
+ logger.exception("Failed to load channel config: %s", fname)
+
+ # ─── Conversation persistence ─────────────────────────────────
+
+ def save_conversations(self, channel_id: str):
+ self._ensure_dirs()
+ convs = [
+ c.model_dump(mode="json")
+ for c in self.conversations.values()
+ if c.channel_id == channel_id
+ ]
+ with open(self._conv_path(channel_id), "w") as f:
+ json.dump(convs, f, indent=2)
+
+ def load_all_conversations(self):
+ self._ensure_dirs()
+ self.conversations.clear()
+ for fname in os.listdir(CHANNELS_SESSIONS_DIR):
+ if fname.endswith(".json"):
+ try:
+ with open(os.path.join(CHANNELS_SESSIONS_DIR, fname)) as f:
+ convs = json.load(f)
+ for data in convs:
+ conv = ChannelConversation(**data)
+ key = f"{conv.channel_id}:{conv.phone_number}"
+ self.conversations[key] = conv
+ except Exception:
+ logger.exception("Failed to load conversations: %s", fname)
+
+ # ─── Adapter management ───────────────────────────────────────
+
+ def get_adapter(self, config: ChannelConfig) -> BaseChannelAdapter:
+ if config.id not in self.adapters:
+ self.adapters[config.id] = self._create_adapter(config)
+ return self.adapters[config.id]
+
+ def _create_adapter(self, config: ChannelConfig) -> BaseChannelAdapter:
+ if config.provider == "twilio":
+ from backend.apps.channels.adapters.twilio_adapter import TwilioAdapter
+ return TwilioAdapter(
+ account_sid=config.credentials.get("account_sid", ""),
+ auth_token=config.credentials.get("auth_token", ""),
+ )
+ elif config.provider == "telnyx":
+ from backend.apps.channels.adapters.telnyx_adapter import TelnyxAdapter
+ return TelnyxAdapter(
+ api_key=config.credentials.get("api_key", ""),
+ public_key=config.credentials.get("public_key", ""),
+ )
+ raise ValueError(f"Unknown provider: {config.provider}")
+
+ # ─── Security checks ─────────────────────────────────────────
+
+ def _check_allowlist(self, config: ChannelConfig, phone: str) -> bool:
+ sec = config.security
+ if phone in sec.blocklist:
+ return False
+ if sec.allowlist and phone not in sec.allowlist:
+ return False
+ return True
+
+ def _check_rate_limit(self, config: ChannelConfig, phone: str) -> bool:
+ sec = config.security
+ return self.rate_limiter.check(
+ phone, sec.rate_limit_per_minute, sec.rate_limit_per_hour
+ )
+
+ # ─── Inbound SMS / WhatsApp ───────────────────────────────────
+
+ async def handle_inbound_sms(
+ self,
+ channel_id: str,
+ from_number: str,
+ body: str,
+ media_urls: list[str] | None = None,
+ ) -> Optional[str]:
+ """Handle an inbound SMS or WhatsApp message. Returns agent response or None."""
+ config = self.configs.get(channel_id)
+ if not config or not config.enabled:
+ logger.warning("Channel %s not found or disabled", channel_id)
+ return None
+
+ if not self._check_allowlist(config, from_number):
+ logger.info("Blocked message from %s (not in allowlist)", from_number)
+ return None
+
+ if not self._check_rate_limit(config, from_number):
+ logger.info("Rate limited: %s", from_number)
+ return None
+
+ # Process media attachments (voice notes)
+ if media_urls and config.stt_config:
+ from backend.apps.channels.media_handler import process_audio_attachment
+ for url in media_urls:
+ transcript = await process_audio_attachment(
+ url, "audio/ogg", config.stt_config,
+ auth=(
+ config.credentials.get("account_sid", ""),
+ config.credentials.get("auth_token", ""),
+ ) if config.provider == "twilio" else None,
+ )
+ if transcript:
+ body = f"{body}\n\n[Voice Note Transcript]: {transcript}" if body else transcript
+
+ # Get or create conversation
+ conv_key = f"{channel_id}:{from_number}"
+ conv = self.conversations.get(conv_key)
+ if not conv:
+ conv = ChannelConversation(
+ channel_id=channel_id,
+ phone_number=from_number,
+ )
+ self.conversations[conv_key] = conv
+
+ # Record inbound message
+ inbound_msg = ChannelMessage(
+ direction="inbound",
+ content=body,
+ media_urls=media_urls or [],
+ channel_type=config.channel_type,
+ )
+ conv.messages.append(inbound_msg)
+ conv.updated_at = datetime.now().isoformat()
+
+ await ws_events.emit_channel_message(
+ channel_id, conv.id, inbound_msg.model_dump(mode="json")
+ )
+
+ # Launch or reuse agent session
+ agent_response = await self._route_to_agent(config, conv, body)
+
+ if agent_response:
+ # Send response back via SMS/WhatsApp
+ adapter = self.get_adapter(config)
+ max_len = PLATFORM_MAX_LENGTH.get(config.channel_type, 1600)
+ chunks = adapter.chunk_message(agent_response, max_len)
+
+ for chunk in chunks:
+ if config.channel_type == "whatsapp":
+ await adapter.send_whatsapp(from_number, config.phone_number, chunk)
+ else:
+ await adapter.send_sms(from_number, config.phone_number, chunk)
+
+ outbound_msg = ChannelMessage(
+ direction="outbound",
+ content=agent_response,
+ channel_type=config.channel_type,
+ )
+ conv.messages.append(outbound_msg)
+ conv.updated_at = datetime.now().isoformat()
+ config.message_count += 1
+ config.last_message_at = datetime.now().isoformat()
+
+ await ws_events.emit_channel_message(
+ channel_id, conv.id, outbound_msg.model_dump(mode="json")
+ )
+
+ self.save_conversations(channel_id)
+ self.save_config(config)
+
+ return agent_response
+
+ # ─── Inbound Voice ────────────────────────────────────────────
+
+ async def handle_inbound_call(
+ self, channel_id: str, call_sid: str, from_number: str, to_number: str
+ ) -> str:
+ """Handle an inbound voice call. Returns initial TwiML."""
+ config = self.configs.get(channel_id)
+ if not config or not config.enabled:
+ adapter = self._fallback_adapter(config)
+ return adapter.generate_twiml_hangup()
+
+ if not self._check_allowlist(config, from_number):
+ adapter = self.get_adapter(config)
+ return adapter.generate_twiml_say("Sorry, you are not authorized to call this number.")
+
+ voice_cfg = config.voice_config or VoiceConfig()
+ adapter = self.get_adapter(config)
+
+ # Create call state
+ call = self.call_manager.create_call(call_sid, channel_id, from_number, to_number)
+ call.transition("connected")
+ call.transition("gathering")
+
+ await ws_events.emit_call_event(channel_id, call_sid, "call_started", {
+ "from": from_number, "to": to_number,
+ })
+
+ # Return TwiML to greet and gather speech
+ from backend.apps.settings.settings import load_settings
+ settings = load_settings()
+ webhook_base = getattr(settings, "webhook_base_url", "") or ""
+ gather_url = f"{webhook_base}/api/channels/webhooks/twilio/voice/gather?channel_id={channel_id}&call_sid={call_sid}"
+
+ return adapter.generate_twiml_gather(
+ prompt=voice_cfg.greeting_message,
+ action_url=gather_url,
+ voice=voice_cfg.voice,
+ language=voice_cfg.language,
+ timeout=voice_cfg.gather_timeout_seconds,
+ )
+
+ async def handle_voice_gather(
+ self, channel_id: str, call_sid: str, speech_result: str
+ ) -> str:
+ """Handle gathered speech from a voice call. Returns response TwiML."""
+ config = self.configs.get(channel_id)
+ if not config:
+ return '
'
+
+ call = self.call_manager.get_call(call_sid)
+ if not call or not call.is_active:
+ adapter = self.get_adapter(config)
+ return adapter.generate_twiml_hangup()
+
+ call.transition("processing")
+ call.add_turn("user", speech_result)
+
+ voice_cfg = config.voice_config or VoiceConfig()
+ adapter = self.get_adapter(config)
+
+ # Route speech to agent
+ conv_key = f"{channel_id}:{call.from_number}"
+ conv = self.conversations.get(conv_key)
+ if not conv:
+ conv = ChannelConversation(
+ channel_id=channel_id,
+ phone_number=call.from_number,
+ )
+ self.conversations[conv_key] = conv
+
+ agent_response = await self._route_to_agent(config, conv, speech_result)
+
+ if not agent_response:
+ agent_response = "I'm sorry, I couldn't process that. Could you try again?"
+
+ call.transition("responding")
+ call.add_turn("assistant", agent_response)
+
+ await ws_events.emit_call_event(channel_id, call_sid, "turn_complete", {
+ "user": speech_result, "assistant": agent_response,
+ })
+
+ # Check if we should continue or end
+ if voice_cfg.mode == "notify":
+ call.transition("completed")
+ return adapter.generate_twiml_say(agent_response, voice=voice_cfg.voice)
+
+ # Conversation mode: say response then gather again
+ from backend.apps.settings.settings import load_settings
+ settings = load_settings()
+ webhook_base = getattr(settings, "webhook_base_url", "") or ""
+ gather_url = f"{webhook_base}/api/channels/webhooks/twilio/voice/gather?channel_id={channel_id}&call_sid={call_sid}"
+
+ call.transition("gathering")
+
+ return (
+ ''
+ "
"
+ f'{_escape_xml(agent_response)}'
+ f''
+ ""
+ f'Are you still there? Goodbye.'
+ ""
+ )
+
+ def handle_call_status(self, call_sid: str, status: str):
+ """Handle Twilio call status callback."""
+ call = self.call_manager.get_call(call_sid)
+ if not call:
+ return
+ if status in ("completed", "busy", "no-answer", "canceled", "failed"):
+ final = "failed" if status == "failed" else "completed"
+ call.transition(final)
+ asyncio.create_task(
+ ws_events.emit_call_event(call.channel_id, call_sid, "call_ended", {
+ "status": status,
+ })
+ )
+
+ # ─── Agent routing ────────────────────────────────────────────
+
+ async def _route_to_agent(
+ self, config: ChannelConfig, conv: ChannelConversation, text: str
+ ) -> Optional[str]:
+ """Send a message to an agent session and wait for the response."""
+ from backend.apps.agents.agent_manager import agent_manager
+ from backend.apps.agents.ws_manager import ws_manager
+
+ # Launch agent if no session exists
+ if not conv.agent_session_id or not agent_manager.get_session(conv.agent_session_id):
+ ac = config.agent_config
+ agent_cfg = AgentConfig(
+ name=f"{config.channel_type}: {conv.phone_number}",
+ model=ac.model,
+ mode=ac.mode,
+ system_prompt=ac.system_prompt,
+ max_turns=ac.max_turns,
+ )
+ if ac.allowed_tools:
+ agent_cfg.allowed_tools = ac.allowed_tools
+
+ session = await agent_manager.launch_agent(agent_cfg)
+ conv.agent_session_id = session.id
+
+ session_id = conv.agent_session_id
+
+ # Set up a future to capture the agent's response
+ response_future: asyncio.Future[str] = asyncio.get_event_loop().create_future()
+
+ async def _on_agent_event(event: str, data: dict):
+ if response_future.done():
+ return
+ if event == "agent:message":
+ msg = data.get("message", {})
+ if msg.get("role") == "assistant":
+ content = msg.get("content", "")
+ if isinstance(content, list):
+ # Extract text from content blocks
+ parts = [
+ b.get("text", "")
+ for b in content
+ if isinstance(b, dict) and b.get("type") == "text"
+ ]
+ content = "\n".join(parts)
+ if content and not response_future.done():
+ response_future.set_result(content)
+ elif event == "agent:status":
+ status = data.get("status", "")
+ if status in ("completed", "error", "stopped") and not response_future.done():
+ response_future.set_result("")
+
+ # Register listener for this session's events
+ # We tap into ws_manager's send_to_session by monkey-patching temporarily
+ original_send = ws_manager.send_to_session
+
+ async def _hooked_send(sid: str, event: str, data: dict):
+ await original_send(sid, event, data)
+ if sid == session_id:
+ await _on_agent_event(event, data)
+
+ ws_manager.send_to_session = _hooked_send
+
+ try:
+ await agent_manager.send_message(session_id, text)
+ response = await asyncio.wait_for(response_future, timeout=120)
+ return response if response else None
+ except asyncio.TimeoutError:
+ logger.warning("Agent response timed out for session %s", session_id)
+ return None
+ except Exception:
+ logger.exception("Error routing to agent")
+ return None
+ finally:
+ ws_manager.send_to_session = original_send
+
+ def _fallback_adapter(self, config: Optional[ChannelConfig] = None) -> BaseChannelAdapter:
+ """Return a minimal adapter for generating hangup TwiML."""
+ from backend.apps.channels.adapters.twilio_adapter import TwilioAdapter
+ return TwilioAdapter("", "")
+
+ # ─── Outbound ─────────────────────────────────────────────────
+
+ async def send_outbound(
+ self, channel_id: str, to_number: str, message: str
+ ) -> dict:
+ config = self.configs.get(channel_id)
+ if not config:
+ raise ValueError(f"Channel {channel_id} not found")
+
+ adapter = self.get_adapter(config)
+ max_len = PLATFORM_MAX_LENGTH.get(config.channel_type, 1600)
+ chunks = adapter.chunk_message(message, max_len)
+ results = []
+
+ for chunk in chunks:
+ if config.channel_type == "whatsapp":
+ r = await adapter.send_whatsapp(to_number, config.phone_number, chunk)
+ else:
+ r = await adapter.send_sms(to_number, config.phone_number, chunk)
+ results.append(r)
+
+ # Record outbound
+ conv_key = f"{channel_id}:{to_number}"
+ conv = self.conversations.get(conv_key)
+ if not conv:
+ conv = ChannelConversation(channel_id=channel_id, phone_number=to_number)
+ self.conversations[conv_key] = conv
+
+ conv.messages.append(ChannelMessage(
+ direction="outbound", content=message, channel_type=config.channel_type,
+ ))
+ conv.updated_at = datetime.now().isoformat()
+ self.save_conversations(channel_id)
+
+ return {"sent": len(chunks), "results": results}
+
+ async def initiate_outbound_call(
+ self, channel_id: str, to_number: str
+ ) -> dict:
+ config = self.configs.get(channel_id)
+ if not config:
+ raise ValueError(f"Channel {channel_id} not found")
+
+ from backend.apps.settings.settings import load_settings
+ settings = load_settings()
+ webhook_base = getattr(settings, "webhook_base_url", "") or ""
+ voice_webhook = f"{webhook_base}/api/channels/webhooks/twilio/voice?channel_id={channel_id}"
+
+ adapter = self.get_adapter(config)
+ result = await adapter.initiate_call(
+ to=to_number,
+ from_=config.phone_number,
+ webhook_url=voice_webhook,
+ )
+ return result
+
+ # ─── Lifecycle ────────────────────────────────────────────────
+
+ async def restore_all(self):
+ self.load_all_configs()
+ self.load_all_conversations()
+ for config in self.configs.values():
+ if config.enabled:
+ try:
+ self.get_adapter(config)
+ config.status = "active"
+ except Exception:
+ config.status = "error"
+ config.status_message = "Failed to initialize adapter"
+
+ async def persist_all(self):
+ for config in self.configs.values():
+ self.save_config(config)
+ self.save_conversations(config.id)
+
+
+def _escape_xml(text: str) -> str:
+ return (
+ text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """)
+ .replace("'", "'")
+ )
+
+
+# Singleton
+from backend.apps.channels.models import VoiceConfig # noqa: E402
+channel_orchestrator = ChannelOrchestrator()
diff --git a/backend/apps/channels/stt_service.py b/backend/apps/channels/stt_service.py
new file mode 100644
index 00000000..5b9a1aed
--- /dev/null
+++ b/backend/apps/channels/stt_service.py
@@ -0,0 +1,142 @@
+"""Provider-abstracted Speech-to-Text service.
+
+Supports: Twilio built-in (via Gather), Deepgram Nova-3, OpenAI Whisper.
+"""
+import logging
+from typing import Optional
+
+import httpx
+
+from backend.apps.channels.models import STTConfig
+from backend.apps.settings.settings import load_settings
+
+logger = logging.getLogger(__name__)
+
+SUPPORTED_AUDIO_FORMATS = {
+ "audio/ogg", "audio/mpeg", "audio/wav", "audio/mp4",
+ "audio/flac", "audio/webm", "audio/x-wav",
+}
+MAX_MEDIA_BYTES = 20 * 1024 * 1024 # 20 MB
+
+
+async def transcribe(
+ audio_bytes: bytes,
+ config: STTConfig,
+ content_type: str = "audio/wav",
+) -> Optional[str]:
+ """Transcribe audio bytes to text using the configured provider chain."""
+ if len(audio_bytes) > MAX_MEDIA_BYTES:
+ logger.warning("Audio exceeds %d bytes limit", MAX_MEDIA_BYTES)
+ return None
+ if len(audio_bytes) < 1024:
+ logger.debug("Audio too short, skipping")
+ return None
+
+ providers = config.fallback_chain or [config.provider]
+
+ for provider in providers:
+ try:
+ if provider == "twilio_builtin":
+ # Twilio STT is handled inline by
— no bytes to process
+ continue
+ elif provider == "deepgram":
+ result = await _deepgram_transcribe(audio_bytes, config, content_type)
+ elif provider == "openai_whisper":
+ result = await _openai_transcribe(audio_bytes, config, content_type)
+ else:
+ logger.warning("Unknown STT provider: %s", provider)
+ continue
+
+ if result:
+ return result
+ except Exception:
+ logger.exception("STT provider %s failed, trying next", provider)
+
+ return None
+
+
+async def transcribe_from_url(
+ url: str, config: STTConfig, content_type: str = "audio/ogg"
+) -> Optional[str]:
+ """Download audio from URL and transcribe."""
+ try:
+ async with httpx.AsyncClient(timeout=60) as client:
+ resp = await client.get(url, follow_redirects=True)
+ resp.raise_for_status()
+ return await transcribe(resp.content, config, content_type)
+ except Exception:
+ logger.exception("Failed to download audio from %s", url)
+ return None
+
+
+async def _deepgram_transcribe(
+ audio_bytes: bytes, config: STTConfig, content_type: str
+) -> Optional[str]:
+ settings = load_settings()
+ api_key = settings.deepgram_api_key if hasattr(settings, "deepgram_api_key") else None
+ if not api_key:
+ logger.warning("Deepgram API key not configured")
+ return None
+
+ try:
+ async with httpx.AsyncClient(timeout=60) as client:
+ resp = await client.post(
+ "https://api.deepgram.com/v1/listen",
+ headers={
+ "Authorization": f"Token {api_key}",
+ "Content-Type": content_type,
+ },
+ params={
+ "model": config.deepgram_model,
+ "language": config.language,
+ "smart_format": "true",
+ "punctuate": "true",
+ },
+ content=audio_bytes,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ return (
+ data.get("results", {})
+ .get("channels", [{}])[0]
+ .get("alternatives", [{}])[0]
+ .get("transcript", "")
+ )
+ except Exception:
+ logger.exception("Deepgram transcription failed")
+ return None
+
+
+async def _openai_transcribe(
+ audio_bytes: bytes, config: STTConfig, content_type: str
+) -> Optional[str]:
+ settings = load_settings()
+ api_key = settings.openai_api_key if hasattr(settings, "openai_api_key") else None
+ if not api_key:
+ logger.warning("OpenAI API key not configured")
+ return None
+
+ ext_map = {
+ "audio/ogg": "ogg",
+ "audio/mpeg": "mp3",
+ "audio/wav": "wav",
+ "audio/x-wav": "wav",
+ "audio/mp4": "m4a",
+ "audio/flac": "flac",
+ "audio/webm": "webm",
+ }
+ ext = ext_map.get(content_type, "wav")
+
+ try:
+ async with httpx.AsyncClient(timeout=60) as client:
+ resp = await client.post(
+ "https://api.openai.com/v1/audio/transcriptions",
+ headers={"Authorization": f"Bearer {api_key}"},
+ files={"file": (f"audio.{ext}", audio_bytes, content_type)},
+ data={"model": "whisper-1", "language": config.language[:2]},
+ )
+ resp.raise_for_status()
+ return resp.json().get("text", "")
+ except Exception:
+ logger.exception("OpenAI Whisper transcription failed")
+ return None
diff --git a/backend/apps/channels/talk_mode.py b/backend/apps/channels/talk_mode.py
new file mode 100644
index 00000000..c4a0e88f
--- /dev/null
+++ b/backend/apps/channels/talk_mode.py
@@ -0,0 +1,163 @@
+"""Browser-based Talk Mode — continuous voice conversation via WebSocket.
+
+Pipeline: Mic → WebSocket → STT → Agent → TTS → WebSocket → Speaker
+
+This runs as a separate WebSocket endpoint /ws/talk/{session_id} that
+streams audio bidirectionally between the browser and the STT/TTS services.
+"""
+import asyncio
+import json
+import logging
+from typing import Optional
+
+from fastapi import WebSocket, WebSocketDisconnect
+
+from backend.apps.channels import stt_service, tts_service
+from backend.apps.channels.models import STTConfig, TTSConfig
+from backend.apps.agents.agent_manager import agent_manager
+from backend.apps.agents.ws_manager import ws_manager
+from backend.apps.settings.settings import load_settings
+
+logger = logging.getLogger(__name__)
+
+# Default configs for talk mode
+DEFAULT_STT = STTConfig(
+ provider="openai_whisper",
+ fallback_chain=["openai_whisper", "deepgram"],
+)
+DEFAULT_TTS = TTSConfig(
+ provider="elevenlabs",
+ skip_short_text=False,
+)
+
+
+async def handle_talk_session(websocket: WebSocket, session_id: str):
+ """Handle a talk-mode WebSocket connection.
+
+ Protocol:
+ - Client sends: {"type": "audio", "data": "", "format": "webm"}
+ - Client sends: {"type": "config", "stt": {...}, "tts": {...}}
+ - Client sends: {"type": "end_utterance"} when silence detected
+ - Server sends: {"type": "transcript", "text": "..."}
+ - Server sends: {"type": "audio", "data": "", "format": "mp3"}
+ - Server sends: {"type": "agent_response", "text": "..."}
+ - Server sends: {"type": "status", "status": "listening|processing|speaking"}
+ """
+ await websocket.accept()
+
+ stt_config = DEFAULT_STT
+ tts_config = DEFAULT_TTS
+ audio_buffer = bytearray()
+
+ try:
+ while True:
+ data = await websocket.receive_text()
+ msg = json.loads(data)
+ msg_type = msg.get("type", "")
+
+ if msg_type == "config":
+ if msg.get("stt"):
+ stt_config = STTConfig(**msg["stt"])
+ if msg.get("tts"):
+ tts_config = TTSConfig(**msg["tts"])
+ await websocket.send_text(json.dumps({"type": "status", "status": "listening"}))
+
+ elif msg_type == "audio":
+ import base64
+ chunk = base64.b64decode(msg.get("data", ""))
+ audio_buffer.extend(chunk)
+
+ elif msg_type == "end_utterance":
+ if not audio_buffer:
+ continue
+
+ await websocket.send_text(json.dumps({"type": "status", "status": "processing"}))
+
+ audio_bytes = bytes(audio_buffer)
+ audio_buffer.clear()
+
+ audio_format = msg.get("format", "webm")
+ content_type = f"audio/{audio_format}"
+
+ # STT
+ transcript = await stt_service.transcribe(
+ audio_bytes, stt_config, content_type
+ )
+
+ if not transcript:
+ await websocket.send_text(json.dumps({"type": "status", "status": "listening"}))
+ continue
+
+ await websocket.send_text(json.dumps({
+ "type": "transcript", "text": transcript,
+ }))
+
+ # Route to agent
+ agent_response = await _get_agent_response(session_id, transcript)
+
+ if agent_response:
+ await websocket.send_text(json.dumps({
+ "type": "agent_response", "text": agent_response,
+ }))
+
+ # TTS
+ await websocket.send_text(json.dumps({"type": "status", "status": "speaking"}))
+
+ audio = await tts_service.synthesize(agent_response, tts_config)
+ if audio:
+ import base64 as b64
+ await websocket.send_text(json.dumps({
+ "type": "audio",
+ "data": b64.b64encode(audio).decode(),
+ "format": "mp3",
+ }))
+
+ await websocket.send_text(json.dumps({"type": "status", "status": "listening"}))
+
+ elif msg_type == "stop":
+ break
+
+ except WebSocketDisconnect:
+ logger.info("Talk mode disconnected for session %s", session_id)
+ except Exception:
+ logger.exception("Talk mode error for session %s", session_id)
+
+
+async def _get_agent_response(session_id: str, text: str) -> Optional[str]:
+ """Send text to agent and wait for response."""
+ session = agent_manager.get_session(session_id)
+ if not session:
+ return None
+
+ response_future: asyncio.Future[str] = asyncio.get_event_loop().create_future()
+
+ original_send = ws_manager.send_to_session
+
+ async def _hooked_send(sid: str, event: str, data: dict):
+ await original_send(sid, event, data)
+ if sid == session_id and not response_future.done():
+ if event == "agent:message":
+ msg = data.get("message", {})
+ if msg.get("role") == "assistant":
+ content = msg.get("content", "")
+ if isinstance(content, list):
+ parts = [
+ b.get("text", "")
+ for b in content
+ if isinstance(b, dict) and b.get("type") == "text"
+ ]
+ content = "\n".join(parts)
+ if content:
+ response_future.set_result(content)
+ elif event == "agent:status":
+ if data.get("status") in ("completed", "error", "stopped"):
+ response_future.set_result("")
+
+ ws_manager.send_to_session = _hooked_send
+ try:
+ await agent_manager.send_message(session_id, text)
+ return await asyncio.wait_for(response_future, timeout=120) or None
+ except asyncio.TimeoutError:
+ return None
+ finally:
+ ws_manager.send_to_session = original_send
diff --git a/backend/apps/channels/tts_service.py b/backend/apps/channels/tts_service.py
new file mode 100644
index 00000000..c3731724
--- /dev/null
+++ b/backend/apps/channels/tts_service.py
@@ -0,0 +1,127 @@
+"""Provider-abstracted Text-to-Speech service.
+
+Supports: Twilio built-in Say, ElevenLabs, OpenAI TTS, Microsoft Edge TTS.
+"""
+import logging
+from typing import Optional
+
+import httpx
+
+from backend.apps.channels.models import TTSConfig
+from backend.apps.settings.settings import load_settings
+
+logger = logging.getLogger(__name__)
+
+
+async def synthesize(text: str, config: TTSConfig) -> Optional[bytes]:
+ """Convert text to audio bytes. Returns None for twilio_say (handled in TwiML)."""
+ if should_skip(text, config):
+ return None
+
+ if len(text) > config.max_tts_chars and config.summarize_long_replies:
+ text = text[: config.max_tts_chars]
+
+ provider = config.provider
+ if provider == "twilio_say":
+ # Twilio renders speech inline via — no audio bytes needed
+ return None
+ elif provider == "elevenlabs":
+ return await _elevenlabs_synthesize(text, config)
+ elif provider == "openai_tts":
+ return await _openai_synthesize(text, config)
+ elif provider == "edge_tts":
+ return await _edge_synthesize(text, config)
+
+ logger.warning("Unknown TTS provider: %s", provider)
+ return None
+
+
+def should_skip(text: str, config: TTSConfig) -> bool:
+ if config.skip_short_text and len(text.strip()) < 20:
+ return True
+ return False
+
+
+async def _elevenlabs_synthesize(text: str, config: TTSConfig) -> Optional[bytes]:
+ settings = load_settings()
+ api_key = settings.elevenlabs_api_key if hasattr(settings, "elevenlabs_api_key") else None
+ if not api_key:
+ logger.warning("ElevenLabs API key not configured, falling back to edge_tts")
+ return await _edge_synthesize(text, config)
+
+ voice_id = config.elevenlabs_voice_id or "21m00Tcm4TlvDq8ikWAM" # Rachel default
+ model_id = config.elevenlabs_model_id
+
+ try:
+ async with httpx.AsyncClient(timeout=30) as client:
+ resp = await client.post(
+ f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}",
+ headers={
+ "xi-api-key": api_key,
+ "Content-Type": "application/json",
+ "Accept": "audio/mpeg",
+ },
+ json={
+ "text": text,
+ "model_id": model_id,
+ "voice_settings": {
+ "stability": 0.5,
+ "similarity_boost": 0.75,
+ },
+ },
+ )
+ resp.raise_for_status()
+ return resp.content
+ except Exception:
+ logger.exception("ElevenLabs TTS failed, falling back to edge_tts")
+ return await _edge_synthesize(text, config)
+
+
+async def _openai_synthesize(text: str, config: TTSConfig) -> Optional[bytes]:
+ settings = load_settings()
+ api_key = settings.openai_api_key if hasattr(settings, "openai_api_key") else None
+ if not api_key:
+ logger.warning("OpenAI API key not configured, falling back to edge_tts")
+ return await _edge_synthesize(text, config)
+
+ try:
+ async with httpx.AsyncClient(timeout=30) as client:
+ resp = await client.post(
+ "https://api.openai.com/v1/audio/speech",
+ headers={
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ },
+ json={
+ "model": "tts-1",
+ "input": text,
+ "voice": config.openai_voice,
+ "response_format": "mp3",
+ },
+ )
+ resp.raise_for_status()
+ return resp.content
+ except Exception:
+ logger.exception("OpenAI TTS failed, falling back to edge_tts")
+ return await _edge_synthesize(text, config)
+
+
+async def _edge_synthesize(text: str, config: TTSConfig) -> Optional[bytes]:
+ """Free fallback TTS via Microsoft Edge neural voices. No API key needed."""
+ try:
+ import edge_tts
+ import tempfile
+ import os
+
+ communicate = edge_tts.Communicate(text, "en-US-JennyNeural")
+ with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
+ tmp_path = f.name
+
+ await communicate.save(tmp_path)
+ with open(tmp_path, "rb") as f:
+ audio = f.read()
+ os.unlink(tmp_path)
+ return audio
+ except Exception:
+ logger.exception("Edge TTS failed")
+ return None
diff --git a/backend/apps/channels/wake_word.py b/backend/apps/channels/wake_word.py
new file mode 100644
index 00000000..a79f70cc
--- /dev/null
+++ b/backend/apps/channels/wake_word.py
@@ -0,0 +1,68 @@
+"""Voice Wake Word Detection — scaffolded interface.
+
+Matches OpenClaw's current state: the interface is defined but full
+implementation is deferred. Supports future integration with Vosk
+(offline) or Porcupine wake word engines.
+
+Usage:
+ This module defines the configuration and interface. Actual wake word
+ detection runs on the client device (macOS/iOS/Android) and sends
+ a "wake" event to the gateway when triggered.
+"""
+import logging
+from typing import Optional
+from pydantic import BaseModel, Field
+
+logger = logging.getLogger(__name__)
+
+
+class WakeWordConfig(BaseModel):
+ """Configuration for wake word detection."""
+ enabled: bool = False
+ wake_words: list[str] = Field(default_factory=lambda: ["hey swarm", "open swarm"])
+ sensitivity: float = 0.5 # 0.0 - 1.0
+ engine: str = "vosk" # "vosk" | "porcupine"
+
+
+class WakeWordManager:
+ """Manages wake word detection state.
+
+ In the current scaffolded implementation, this stores configuration
+ and handles wake events from client devices. The actual audio
+ processing runs on the client side.
+ """
+
+ def __init__(self):
+ self.config = WakeWordConfig()
+ self._active_devices: dict[str, bool] = {}
+
+ def update_config(self, **kwargs):
+ for k, v in kwargs.items():
+ if hasattr(self.config, k):
+ setattr(self.config, k, v)
+
+ def register_device(self, device_id: str):
+ self._active_devices[device_id] = True
+ logger.info("Wake word device registered: %s", device_id)
+
+ def unregister_device(self, device_id: str):
+ self._active_devices.pop(device_id, None)
+
+ def handle_wake_event(self, device_id: str, wake_word: str) -> bool:
+ """Called when a client device detects a wake word.
+
+ Returns True if the wake event should trigger a talk session.
+ """
+ if not self.config.enabled:
+ return False
+ if device_id not in self._active_devices:
+ return False
+ if wake_word.lower() not in [w.lower() for w in self.config.wake_words]:
+ return False
+
+ logger.info("Wake word detected: '%s' from device %s", wake_word, device_id)
+ return True
+
+
+# Singleton
+wake_word_manager = WakeWordManager()
diff --git a/backend/apps/channels/ws_events.py b/backend/apps/channels/ws_events.py
new file mode 100644
index 00000000..75e0853b
--- /dev/null
+++ b/backend/apps/channels/ws_events.py
@@ -0,0 +1,40 @@
+"""WebSocket event emitters for channel events.
+
+Uses the existing ws_manager.broadcast_global() — no new WebSocket
+infrastructure needed.
+"""
+import logging
+from backend.apps.agents.ws_manager import ws_manager
+
+logger = logging.getLogger(__name__)
+
+
+async def emit_channel_message(
+ channel_id: str, conversation_id: str, message: dict
+):
+ await ws_manager.broadcast_global("channel:message", {
+ "channel_id": channel_id,
+ "conversation_id": conversation_id,
+ "message": message,
+ })
+
+
+async def emit_channel_status(
+ channel_id: str, status: str, detail: str = ""
+):
+ await ws_manager.broadcast_global("channel:status", {
+ "channel_id": channel_id,
+ "status": status,
+ "detail": detail,
+ })
+
+
+async def emit_call_event(
+ channel_id: str, call_sid: str, event: str, data: dict | None = None
+):
+ await ws_manager.broadcast_global("channel:call_event", {
+ "channel_id": channel_id,
+ "call_sid": call_sid,
+ "event": event,
+ **(data or {}),
+ })
diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py
index 29d32af3..5a21d4a6 100644
--- a/backend/apps/dashboards/dashboards.py
+++ b/backend/apps/dashboards/dashboards.py
@@ -123,8 +123,10 @@ async def list_dashboards():
@dashboards.router.post("/create")
async def create_dashboard(body: DashboardCreate):
+ from backend.apps.analytics.collector import record as _analytics
dashboard = Dashboard(name=body.name)
_save(dashboard)
+ _analytics("dashboard.created", {}, dashboard_id=dashboard.id)
return dashboard.model_dump(mode="json")
@@ -151,13 +153,10 @@ async def generate_name(dashboard_id: str):
fallback = prompts[0][:40]
try:
- import anthropic
from backend.apps.settings.settings import load_settings
+ from backend.apps.settings.credentials import get_anthropic_client
global_settings = load_settings()
- if not global_settings.anthropic_api_key:
- raise ValueError("API key not configured")
-
- client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key)
+ client = get_anthropic_client(global_settings)
if len(prompts) == 1:
system = (
@@ -173,7 +172,7 @@ async def generate_name(dashboard_id: str):
user_content = "\n".join(f"- {p}" for p in prompts)
resp = await client.messages.create(
- model="claude-haiku-4-20250414",
+ model="claude-haiku-4-5-20251001",
max_tokens=30,
system=system,
messages=[{"role": "user", "content": user_content}],
diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py
index b6cd6bef..74fc4ee8 100644
--- a/backend/apps/dashboards/models.py
+++ b/backend/apps/dashboards/models.py
@@ -34,8 +34,8 @@ class BrowserCardPosition(BaseModel):
activeTabId: str = ""
x: float = 0
y: float = 0
- width: float = 640
- height: float = 480
+ width: float = 1280
+ height: float = 800
class DashboardLayout(BaseModel):
diff --git a/backend/apps/modes/models.py b/backend/apps/modes/models.py
index 03c201fb..0afc742c 100644
--- a/backend/apps/modes/models.py
+++ b/backend/apps/modes/models.py
@@ -76,43 +76,28 @@ BUILTIN_MODES: list[Mode] = [
),
Mode(
id="view-builder",
- name="View Builder",
- description="Create and iterate on reusable View artifacts.",
+ name="App Builder",
+ description="Create and iterate on reusable App artifacts.",
system_prompt=(
- "You are helping the user build a reusable View — a self-contained "
- "web app rendered in an iframe.\n\n"
- "Your working directory is a dedicated workspace folder for this view. "
- "You can create any file structure you need using the Write tool.\n\n"
- "## Required files\n\n"
- "1. **index.html** — The entry point. A complete HTML document. "
- "React 18 is available via esm.sh CDN imports:\n"
- ' \n'
- " The structured input data is available at `window.OUTPUT_INPUT` (object) "
- "and any server-side result at `window.OUTPUT_BACKEND_RESULT`.\n\n"
- "2. **schema.json** — A JSON Schema object defining the structured input "
- "the view accepts. Example:\n"
- ' {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}\n\n'
- "3. **meta.json** — Metadata for this view. Always write this file with "
- "a short name and one-sentence description. Example:\n"
- ' {"name":"Sales Dashboard","description":"Interactive dashboard showing sales metrics"}\n\n'
- "## Optional files\n\n"
- "- **backend.py** — Python code that receives `input_data` as "
- "a global dict and must assign its result to a global `result` dict.\n"
- "- **Any additional files** — You can create subdirectories and split code "
- "across multiple files. For example:\n"
- " - `components/Chart.js` — Reusable components\n"
- " - `utils/helpers.js` — Utility functions\n"
- " - `styles/main.css` — Stylesheets\n\n"
- "Files are served from the workspace, so relative imports work naturally:\n"
- ' ``\n'
- ' ``\n'
- " `import { helper } from './utils/helpers.js'` (in ES modules)\n\n"
- "## Guidelines\n\n"
- "Write files immediately when you have code ready. The user can see "
- "a live preview that auto-refreshes from these files. Always write the "
- "complete file content (do not use Edit for partial patches on first creation). "
- "For complex views, split code into separate files to keep things organized."
+ "You are an App Builder — an AI assistant that creates self-contained "
+ "web apps rendered in an iframe preview.\n\n"
+ "Your working directory is a dedicated workspace folder pre-seeded with "
+ "template files. Read the existing files before making changes.\n\n"
+ "## Critical rules\n\n"
+ "- The entry point MUST be named `index.html`. Never rename it or create "
+ "a different HTML file as the main entry point.\n"
+ "- Write files immediately when you have code ready — the user sees a "
+ "live preview that auto-refreshes from these files.\n"
+ "- Always write the complete file content on first creation (do not use "
+ "Edit for partial patches on new files).\n"
+ "- For complex apps, split code into separate files (JS, CSS, etc.) "
+ "and reference them from index.html with relative paths.\n"
+ "- Always update meta.json with a short name and one-sentence description.\n"
+ "- Build beautiful, polished UIs with modern design — dark themes, smooth "
+ "transitions, proper spacing, and responsive layouts.\n\n"
+ "Read the SKILL.md reference in your workspace for the full technical "
+ "specification of the App platform (available globals, file conventions, "
+ "schema format, backend.py usage, and examples)."
),
tools=None,
default_next_mode=None,
diff --git a/backend/apps/modes/modes.py b/backend/apps/modes/modes.py
index 6d393143..37aabd90 100644
--- a/backend/apps/modes/modes.py
+++ b/backend/apps/modes/modes.py
@@ -59,7 +59,8 @@ def load_mode(mode_id: str) -> Mode | None:
@modes.router.get("/list")
async def list_modes():
- return {"modes": [m.model_dump() for m in _load_all()]}
+ builtin_defaults = {m.id: m.model_dump() for m in BUILTIN_MODES}
+ return {"modes": [m.model_dump() for m in _load_all()], "builtin_defaults": builtin_defaults}
@modes.router.get("/{mode_id}")
@@ -93,6 +94,16 @@ async def update_mode(mode_id: str, body: ModeUpdate):
return {"ok": True, "mode": mode.model_dump()}
+@modes.router.post("/{mode_id}/reset")
+async def reset_mode(mode_id: str):
+ """Reset a built-in mode to its hardcoded defaults."""
+ builtin = next((m for m in BUILTIN_MODES if m.id == mode_id), None)
+ if not builtin:
+ raise HTTPException(status_code=400, detail="Only built-in modes can be reset")
+ _save(builtin)
+ return {"ok": True, "mode": builtin.model_dump()}
+
+
@modes.router.delete("/{mode_id}")
async def delete_mode(mode_id: str):
mode = _load(mode_id)
diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py
new file mode 100644
index 00000000..c53bf52a
--- /dev/null
+++ b/backend/apps/nine_router.py
@@ -0,0 +1,199 @@
+"""Auto-start and manage 9Router subprocess.
+
+9Router is a free AI subscription proxy that lets users connect their
+Claude/ChatGPT/Gemini subscriptions to OpenSwarm without API keys.
+
+It runs silently in the background on port 20128 and exposes an
+OpenAI-compatible API at localhost:20128/v1.
+"""
+
+import asyncio
+import logging
+import os
+import shutil
+import subprocess
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+NINE_ROUTER_PORT = 20128
+NINE_ROUTER_URL = f"http://localhost:{NINE_ROUTER_PORT}"
+NINE_ROUTER_API = f"{NINE_ROUTER_URL}/api"
+NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
+
+_process: subprocess.Popen | None = None
+
+
+def is_running() -> bool:
+ """Check if 9Router is running."""
+ try:
+ r = httpx.get(f"{NINE_ROUTER_V1}/models", timeout=2.0)
+ return r.status_code == 200
+ except Exception:
+ return False
+
+
+async def ensure_running():
+ """Start 9Router if not already running."""
+ global _process
+ if is_running():
+ logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
+ return
+
+ npx = shutil.which("npx")
+ if not npx:
+ logger.warning("npx not found — cannot auto-start 9Router. Install Node.js or run 9Router manually.")
+ return
+
+ logger.info("Starting 9Router on port %d...", NINE_ROUTER_PORT)
+ try:
+ env = {**os.environ, "PORT": str(NINE_ROUTER_PORT)}
+ _process = subprocess.Popen(
+ [npx, "9router"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ env=env,
+ )
+
+ # Wait up to 15 seconds for it to start
+ for _ in range(30):
+ await asyncio.sleep(0.5)
+ if is_running():
+ logger.info("9Router started successfully")
+ return
+
+ logger.warning("9Router did not start within 15 seconds")
+ except Exception as e:
+ logger.warning(f"Failed to start 9Router: {e}")
+
+
+def stop():
+ """Stop the 9Router subprocess."""
+ global _process
+ if _process:
+ try:
+ _process.terminate()
+ _process.wait(timeout=5)
+ except Exception:
+ try:
+ _process.kill()
+ except Exception:
+ pass
+ _process = None
+ logger.info("9Router stopped")
+
+
+# ---------------------------------------------------------------------------
+# API proxy helpers — call 9Router's API from OpenSwarm
+# ---------------------------------------------------------------------------
+
+async def get_providers() -> list[dict]:
+ """Get all providers and their connection status from 9Router."""
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ r = await client.get(f"{NINE_ROUTER_API}/providers")
+ if r.status_code == 200:
+ return r.json()
+ except Exception as e:
+ logger.debug(f"9Router providers fetch failed: {e}")
+ return []
+
+
+async def start_oauth(provider: str) -> dict:
+ """Start OAuth flow for a provider.
+
+ For device_code providers (github, qwen, kiro): returns {user_code, verification_uri, device_code}
+ For authorization_code providers (claude, codex, gemini-cli): returns {authUrl, codeVerifier, state}
+ """
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ # Try device-code flow first
+ try:
+ r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code")
+ if r.status_code == 200:
+ data = r.json()
+ return {
+ "flow": "device_code",
+ "user_code": data.get("user_code", ""),
+ "verification_uri": data.get("verification_uri", data.get("verification_uri_complete", "")),
+ "device_code": data.get("device_code", ""),
+ "code_verifier": data.get("codeVerifier", ""),
+ "extra_data": {k: v for k, v in data.items() if k.startswith("_")},
+ }
+ except Exception:
+ pass
+
+ # Authorization code flow — redirect to 9Router's own callback page
+ # (Anthropic only accepts redirect URIs registered with 9Router's client ID)
+ callback_url = f"http://localhost:{NINE_ROUTER_PORT}/callback"
+ r = await client.get(
+ f"{NINE_ROUTER_API}/oauth/{provider}/authorize",
+ params={"redirect_uri": callback_url},
+ )
+ r.raise_for_status()
+ data = r.json()
+ return {
+ "flow": "authorization_code",
+ "auth_url": data.get("authUrl", ""),
+ "code_verifier": data.get("codeVerifier", ""),
+ "state": data.get("state", ""),
+ "redirect_uri": callback_url,
+ }
+
+
+async def poll_oauth(provider: str, device_code: str, code_verifier: str | None = None, extra_data: dict | None = None) -> dict:
+ """Poll for OAuth completion.
+
+ Returns: {success: true, connection: {...}} or {success: false, pending: true}
+ """
+ body: dict = {"deviceCode": device_code}
+ if code_verifier:
+ body["codeVerifier"] = code_verifier
+ if extra_data:
+ body["extraData"] = extra_data
+
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ r = await client.post(
+ f"{NINE_ROUTER_API}/oauth/{provider}/poll",
+ json=body,
+ )
+ r.raise_for_status()
+ return r.json()
+
+
+async def exchange_oauth(provider: str, code: str, redirect_uri: str, code_verifier: str, state: str = "") -> dict:
+ """Exchange OAuth code for tokens via 9Router."""
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ r = await client.post(
+ f"{NINE_ROUTER_API}/oauth/{provider}/exchange",
+ json={
+ "code": code,
+ "redirectUri": redirect_uri,
+ "codeVerifier": code_verifier,
+ "state": state,
+ },
+ )
+ r.raise_for_status()
+ return r.json()
+
+
+async def get_models() -> list[dict]:
+ """Get all available models from 9Router."""
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ r = await client.get(f"{NINE_ROUTER_V1}/models")
+ if r.status_code == 200:
+ data = r.json()
+ models = data.get("data", [])
+ return [
+ {
+ "value": m.get("id", ""),
+ "label": m.get("id", "").split("/")[-1] if "/" in m.get("id", "") else m.get("id", ""),
+ "context_window": 200_000,
+ "provider": m.get("owned_by", "subscription"),
+ }
+ for m in models
+ ]
+ except Exception as e:
+ logger.debug(f"9Router models fetch failed: {e}")
+ return []
diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py
index 206e29e8..668e8b55 100644
--- a/backend/apps/outputs/outputs.py
+++ b/backend/apps/outputs/outputs.py
@@ -15,6 +15,7 @@ from backend.apps.outputs.models import (
WorkspaceSeedRequest,
)
from backend.apps.outputs.executor import execute_backend_code
+from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL, VIEW_TEMPLATE_FILES
from backend.apps.settings.settings import load_settings
logger = logging.getLogger(__name__)
@@ -22,7 +23,7 @@ logger = logging.getLogger(__name__)
MODEL_MAP = {
"sonnet": "claude-sonnet-4-20250514",
"opus": "claude-opus-4-20250514",
- "haiku": "claude-haiku-4-20250414",
+ "haiku": "claude-haiku-4-5-20251001",
}
@@ -31,13 +32,11 @@ def _resolve_model(short_name: str) -> str:
def _get_anthropic_client():
- """Create an AsyncAnthropic client using the API key from app settings."""
- import anthropic
+ """Create an AsyncAnthropic client using credentials from app settings."""
+ from backend.apps.settings.credentials import get_anthropic_client
settings = load_settings()
- if not settings.anthropic_api_key:
- raise ValueError("Anthropic API key not configured. Set it in Settings.")
- return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
+ return get_anthropic_client(settings)
def _validate_against_schema(data: dict, schema: dict) -> str | None:
@@ -235,6 +234,14 @@ async def seed_workspace(body: WorkspaceSeedRequest):
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
+ else:
+ for rel_path, content in VIEW_TEMPLATE_FILES.items():
+ full_path = os.path.join(folder, rel_path)
+ with open(full_path, "w") as f:
+ f.write(content)
+
+ with open(os.path.join(folder, "SKILL.md"), "w") as f:
+ f.write(VIEW_BUILDER_SKILL)
if body.meta:
with open(os.path.join(folder, "meta.json"), "w") as f:
diff --git a/backend/apps/outputs/view_builder_skill.md b/backend/apps/outputs/view_builder_skill.md
new file mode 100644
index 00000000..1e78377d
--- /dev/null
+++ b/backend/apps/outputs/view_builder_skill.md
@@ -0,0 +1,223 @@
+# App Builder — Platform Reference
+
+You are building an **App**: a self-contained web app served in an iframe.
+The workspace you're working in is the source of truth — every file you write
+here is served directly to the live preview.
+
+---
+
+## File conventions
+
+| File | Required | Purpose |
+|------|----------|---------|
+| `index.html` | **Yes** | Entry point. Must be a complete HTML document. This is the ONLY file the preview iframe loads — never rename it. |
+| `meta.json` | **Yes** | `{"name":"…","description":"…"}` — displayed in the UI header. Always write this. |
+| `schema.json` | Recommended | JSON Schema defining the input form (the "Test Input" tab). |
+| `backend.py` | Optional | Server-side Python executed before rendering. |
+| Everything else | Optional | JS, CSS, images, subdirectories — referenced from `index.html` via relative paths. |
+
+### ⚠️ Do NOT
+
+- Name the main HTML file anything other than `index.html` — the platform
+ will not find it and the preview will be blank.
+- Use `document.write()` — it breaks the injected data globals.
+- Assume any external server or API is available unless the user provides one.
+
+---
+
+## Injected globals
+
+Before `index.html` loads, the platform injects two globals:
+
+```javascript
+window.OUTPUT_INPUT // Object — structured input from the schema form
+window.OUTPUT_BACKEND_RESULT // Object | null — result from backend.py execution
+```
+
+These are available immediately in any `
+```
+
+ES module imports between JS files:
+
+```javascript
+// components/Chart.js
+import { formatNumber } from '../utils/helpers.js';
+```
+
+---
+
+## Using React
+
+React 18 is available via esm.sh CDN — no build step needed:
+
+```html
+
+
+
+```
+
+Other CDN libraries work too — use `https://esm.sh/` or `https://cdn.jsdelivr.net/npm/` for any npm package.
+
+---
+
+## Design guidelines
+
+- **Dark theme by default** — use dark backgrounds (#0f1117, #1a1d27) with
+ light text (#e2e8f0) unless the user requests otherwise.
+- **Modern aesthetics** — rounded corners (8-12px), subtle borders, box shadows,
+ smooth transitions (0.15-0.3s ease).
+- **Responsive** — use flexbox/grid, test at different sizes.
+- **Typography** — system font stack for UI, monospace for code/data.
+- **Color accents** — use a single accent color with variations for hover/active states.
+- **Spacing** — consistent padding (12-20px), adequate whitespace between sections.
+- **Interactivity** — hover effects, focus states, loading indicators where appropriate.
+
+---
+
+## Complete minimal example
+
+```html
+
+
+
+
+
+ My App
+
+
+
+
+
+
+
+```
diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py
new file mode 100644
index 00000000..0aada357
--- /dev/null
+++ b/backend/apps/outputs/view_builder_templates.py
@@ -0,0 +1,74 @@
+"""Default template files seeded into new App Builder workspaces."""
+
+import os
+
+_SKILL_PATH = os.path.join(os.path.dirname(__file__), "view_builder_skill.md")
+
+with open(_SKILL_PATH) as _f:
+ VIEW_BUILDER_SKILL = _f.read()
+
+VIEW_TEMPLATE_INDEX = """\
+
+
+
+
+
+ App
+
+
+
+
+
Ready
+
Describe what you want to build and the agent will update this app.
+
+
+
+
+"""
+
+VIEW_TEMPLATE_SCHEMA = """\
+{
+ "type": "object",
+ "properties": {},
+ "required": []
+}
+"""
+
+VIEW_TEMPLATE_META = """\
+{
+ "name": "",
+ "description": ""
+}
+"""
+
+VIEW_TEMPLATE_FILES = {
+ "index.html": VIEW_TEMPLATE_INDEX,
+ "schema.json": VIEW_TEMPLATE_SCHEMA,
+ "meta.json": VIEW_TEMPLATE_META,
+}
diff --git a/backend/apps/settings/credentials.py b/backend/apps/settings/credentials.py
new file mode 100644
index 00000000..9d3c5e14
--- /dev/null
+++ b/backend/apps/settings/credentials.py
@@ -0,0 +1,141 @@
+"""Centralized credential resolution for LLM API calls.
+
+Supports multiple providers: Anthropic (native), OpenAI, Gemini,
+OpenRouter, and user-configured custom providers.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import anthropic
+ from backend.apps.settings.models import AppSettings
+
+OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.ai"
+
+
+def _check_9router() -> bool:
+ """Check if 9Router is running locally."""
+ try:
+ import httpx
+ r = httpx.get("http://localhost:20128/v1/models", timeout=2.0)
+ return r.status_code == 200
+ except Exception:
+ return False
+
+
+def validate_credentials(settings: AppSettings, provider: str = "anthropic") -> None:
+ """Raise ValueError if credentials are missing for the given provider.
+
+ Allows through if 9Router is running as a fallback.
+ Handles both display names ('Anthropic') and lowercase ('anthropic').
+ """
+ p = provider.lower().strip()
+
+ # 9Router or GitHub Copilot providers don't need traditional credentials
+ if p in ("9router", "github copilot", "copilot"):
+ return
+
+ # If 9Router is running, all providers are accessible
+ if _check_9router():
+ return
+
+ if p == "anthropic":
+ if getattr(settings, "connection_mode", "own_key") == "managed":
+ if not getattr(settings, "openswarm_auth_token", None):
+ raise ValueError("Open Swarm account not connected. Sign in via Settings → API.")
+ return
+ if settings.anthropic_api_key:
+ return
+ raise ValueError("Anthropic API key not configured. Set it in Settings, or connect a subscription.")
+ elif p == "openai":
+ if settings.openai_api_key:
+ return
+ raise ValueError("OpenAI API key not configured. Set it in Settings, or connect a subscription.")
+ elif p in ("gemini", "google"):
+ if getattr(settings, "google_api_key", None):
+ return
+ raise ValueError("Google API key not configured. Set it in Settings, or connect a subscription.")
+ elif p == "openrouter":
+ if getattr(settings, "openrouter_api_key", None):
+ return
+ raise ValueError("OpenRouter API key not configured. Set it in Settings.")
+ elif p in ("xai", "meta", "deepseek", "mistral", "qwen", "cohere"):
+ # These route through OpenRouter — need either OpenRouter key or 9Router
+ if getattr(settings, "openrouter_api_key", None):
+ return
+ raise ValueError(f"{provider} requires an OpenRouter API key, or connect a subscription via 9Router.")
+ else:
+ # Custom provider — check if it exists in custom_providers
+ for cp in getattr(settings, "custom_providers", []):
+ if cp.name.lower() == p:
+ return
+ # Unknown provider — allow through (create_provider will handle the error)
+ return
+
+
+def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str, str]:
+ """Return credential dict for a specific provider."""
+ validate_credentials(settings, provider)
+
+ if provider == "anthropic":
+ if getattr(settings, "connection_mode", "own_key") == "managed":
+ return {
+ "auth_token": getattr(settings, "openswarm_auth_token", "") or "",
+ "base_url": getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL,
+ }
+ return {"api_key": settings.anthropic_api_key or ""}
+
+ if provider == "openai":
+ return {"api_key": settings.openai_api_key or ""}
+
+ if provider == "gemini":
+ return {"api_key": getattr(settings, "google_api_key", "") or ""}
+
+ if provider == "openrouter":
+ return {"api_key": getattr(settings, "openrouter_api_key", "") or ""}
+
+ # Custom provider
+ for cp in getattr(settings, "custom_providers", []):
+ if cp.name == provider:
+ return {"api_key": cp.api_key, "base_url": cp.base_url}
+
+ raise ValueError(f"No credentials for provider: {provider}")
+
+
+# ---------------------------------------------------------------------------
+# Legacy helpers (kept for backward compat during migration)
+# ---------------------------------------------------------------------------
+
+def get_agent_sdk_env(settings: AppSettings) -> dict[str, str]:
+ """Return the env dict for ClaudeAgentOptions based on connection mode.
+
+ DEPRECATED: Use create_provider() from providers.registry instead.
+ """
+ validate_credentials(settings, "anthropic")
+
+ if getattr(settings, "connection_mode", "own_key") == "managed":
+ proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
+ return {
+ "ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_auth_token", ""),
+ "ANTHROPIC_BASE_URL": proxy_url,
+ }
+
+ return {"ANTHROPIC_API_KEY": settings.anthropic_api_key}
+
+
+def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
+ """Return a configured AsyncAnthropic client based on connection mode."""
+ import anthropic
+
+ validate_credentials(settings, "anthropic")
+
+ if getattr(settings, "connection_mode", "own_key") == "managed":
+ proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
+ return anthropic.AsyncAnthropic(
+ auth_token=getattr(settings, "openswarm_auth_token", None),
+ base_url=proxy_url,
+ )
+
+ return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py
index 343b245e..d69c6e52 100644
--- a/backend/apps/settings/models.py
+++ b/backend/apps/settings/models.py
@@ -1,12 +1,22 @@
-from pydantic import BaseModel
-from typing import Optional
+from pydantic import BaseModel, Field
+from typing import Optional, Any
DEFAULT_SYSTEM_PROMPT = (
- '"Ask the user as many follow ups as needed in order to eliminate any possible ambiguity. '
- "When asking the user questions, use the AskUserQuestion tool.\n\n"
- "You are an unstopable Agent that does whatever is needed to achieve the task. "
- "You are particularly gifted at coding, so when needed, transpose ordinary tasks into coding tasks.\n\n"
- 'If a Browser is selected, prioritize this over other tools when it makes sense (so the user also has observability)."'
+ "You are a personal AI assistant running inside OpenSwarm.\n\n"
+ "## Tool Priority\n"
+ "When a dedicated MCP tool exists for a task, use it directly — do not use the browser for things MCP tools can handle.\n"
+ "Priority order:\n"
+ "1. MCP tools first (Reddit, Google Workspace, Twitter, etc.) — fastest and most reliable\n"
+ "2. WebSearch / WebFetch — for general web lookups without a dedicated MCP\n"
+ "3. BrowserAgent — only when you need to visually interact with a website, fill forms, or do something no other tool can handle\n\n"
+ "## Tool Call Style\n"
+ "Default: do not narrate routine tool calls — just call the tool.\n"
+ "Narrate only when it helps: multi-step work, complex problems, or when the user explicitly asks.\n"
+ "Keep narration brief. Use plain language.\n\n"
+ "## Interaction Style\n"
+ "Be direct and action-oriented. Do not ask clarifying questions unless genuinely ambiguous — "
+ "make reasonable assumptions and act. If you need to ask, use the AskUserQuestion tool.\n"
+ "Do not over-explain what you are about to do. Just do it and show the results.\n"
)
@@ -21,3 +31,37 @@ class AppSettings(BaseModel):
new_agent_shortcut: str = "Meta+l"
anthropic_api_key: Optional[str] = None
browser_homepage: str = "https://www.google.com"
+ # Telephony / Channel credentials
+ twilio_account_sid: Optional[str] = None
+ twilio_auth_token: Optional[str] = None
+ telnyx_api_key: Optional[str] = None
+ elevenlabs_api_key: Optional[str] = None
+ deepgram_api_key: Optional[str] = None
+ openai_api_key: Optional[str] = None
+ google_api_key: Optional[str] = None
+ openrouter_api_key: Optional[str] = None
+ custom_providers: list["CustomProvider"] = Field(default_factory=list)
+ webhook_base_url: Optional[str] = None
+ # Dashboard / UI preferences
+ auto_select_mode_on_new_agent: bool = False
+ expand_new_chats_in_dashboard: bool = False
+ auto_reveal_sub_agents: bool = True
+ dev_mode: bool = False
+ # Subscription tokens (from CLI tools — alternative to API keys)
+ claude_subscription_token: Optional[str] = None
+ openai_subscription_token: Optional[str] = None
+ gemini_subscription_token: Optional[str] = None
+ # GitHub Copilot
+ copilot_github_token: Optional[str] = None
+ copilot_token: Optional[str] = None
+ copilot_token_expires: Optional[float] = None
+ # Analytics: opted in by default, user can toggle off
+ analytics_opt_in: bool = True
+ installation_id: Optional[str] = None
+
+
+class CustomProvider(BaseModel):
+ name: str
+ base_url: str
+ api_key: str = ""
+ models: list[dict[str, Any]] = Field(default_factory=list)
diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py
index e590d9a4..d0c8ca41 100644
--- a/backend/apps/settings/settings.py
+++ b/backend/apps/settings/settings.py
@@ -38,6 +38,13 @@ def load_settings() -> AppSettings:
return AppSettings()
+def _save_settings(settings: AppSettings):
+ """Persist settings to JSON file."""
+ os.makedirs(DATA_DIR, exist_ok=True)
+ with open(SETTINGS_FILE, "w") as f:
+ json.dump(settings.model_dump(), f, indent=2)
+
+
@settings.router.get("")
async def get_settings():
return load_settings().model_dump()
diff --git a/backend/apps/tools_lib/models.py b/backend/apps/tools_lib/models.py
index 3c0cc24e..857ccc3a 100644
--- a/backend/apps/tools_lib/models.py
+++ b/backend/apps/tools_lib/models.py
@@ -5,6 +5,7 @@ from uuid import uuid4
class BuiltinTool(BaseModel):
name: str
+ display_name: Optional[str] = None
description: str
category: str = "filesystem"
deferred: bool = False
@@ -33,6 +34,23 @@ BUILTIN_TOOLS: list[BuiltinTool] = [
BuiltinTool(name="CronList", description="List all scheduled tasks", category="scheduling", deferred=True),
BuiltinTool(name="CronDelete", description="Delete a scheduled task", category="scheduling", deferred=True),
BuiltinTool(name="RenderOutput", description="Render a reusable View artifact with structured input data", category="views", deferred=True),
+ # Agent tools
+ BuiltinTool(name="Agent", display_name="CreateAgent", description="Spawn a sub-agent to handle a complex subtask", category="agents"),
+ BuiltinTool(name="InvokeAgent", description="Invoke a copy of an existing agent with a new message, preserving full conversation context", category="agents"),
+ # Browser delegation tools (Layer 1 — what the main agent calls)
+ BuiltinTool(name="CreateBrowserAgent", description="Create a new browser and run a task on it", category="browser_delegation"),
+ BuiltinTool(name="BrowserAgent", description="Delegate a browser task to an existing browser agent", category="browser_delegation"),
+ BuiltinTool(name="BrowserAgents", description="Run multiple browser tasks in parallel on existing browsers", category="browser_delegation"),
+ # Browser action tools (Layer 2 — what the sub-agent executes)
+ BuiltinTool(name="BrowserScreenshot", description="Capture a screenshot of the browser page", category="browser_action"),
+ BuiltinTool(name="BrowserNavigate", description="Navigate the browser to a URL", category="browser_action"),
+ BuiltinTool(name="BrowserClick", description="Click an element by CSS selector", category="browser_action"),
+ BuiltinTool(name="BrowserType", description="Type text into an input element", category="browser_action"),
+ BuiltinTool(name="BrowserEvaluate", description="Execute JavaScript in the browser", category="browser_action"),
+ BuiltinTool(name="BrowserGetText", description="Get visible text content of the page", category="browser_action"),
+ BuiltinTool(name="BrowserGetElements", description="List interactive elements with CSS selectors", category="browser_action"),
+ BuiltinTool(name="BrowserScroll", description="Scroll the page up or down", category="browser_action"),
+ BuiltinTool(name="BrowserWait", description="Wait for page loads or animations", category="browser_action"),
]
diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py
index f19add53..0abb4bf7 100644
--- a/backend/apps/tools_lib/tools_lib.py
+++ b/backend/apps/tools_lib/tools_lib.py
@@ -18,9 +18,11 @@ from backend.apps.tools_lib.models import ToolDefinition, ToolCreate, ToolUpdate
logger = logging.getLogger(__name__)
-from backend.config.paths import BACKEND_DIR, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
+from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
load_dotenv(os.path.join(BACKEND_DIR, ".env"))
+if os.environ.get("OPENSWARM_PACKAGED") == "1":
+ load_dotenv(os.path.join(os.path.dirname(DATA_ROOT), ".env"), override=True)
@asynccontextmanager
@@ -380,6 +382,7 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
config["command"] = resolved
env = config.setdefault("env", {})
env.setdefault("PATH", _augmented_path())
+ env.setdefault("PYTHONPATH", "")
return config
@@ -510,7 +513,7 @@ async def _discover_mcp_tools_http(url: str, headers: dict | None = None) -> lis
raise HTTPException(status_code=502, detail="Empty response from MCP server")
tools_list = data.get("result", {}).get("tools", [])
- return [{"name": t.get("name", ""), "description": t.get("description", "")} for t in tools_list]
+ return [{"name": t.get("name", ""), "description": t.get("description", ""), "inputSchema": t.get("inputSchema")} for t in tools_list]
async def _discover_mcp_tools_sse(url: str, headers: dict | None = None) -> list[dict]:
@@ -533,7 +536,7 @@ async def _discover_mcp_tools_sse(url: str, headers: dict | None = None) -> list
) as session:
await session.initialize()
result = await session.list_tools()
- return [{"name": t.name, "description": t.description or ""} for t in result.tools]
+ return [{"name": t.name, "description": t.description or "", "inputSchema": t.inputSchema if t.inputSchema else None} for t in result.tools]
except BaseExceptionGroup as eg:
first = eg.exceptions[0] if eg.exceptions else eg
raise HTTPException(status_code=502, detail=f"SSE discovery failed: {first}") from first
@@ -546,6 +549,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
raise HTTPException(status_code=400, detail=f"Command '{command}' not found on PATH or common install locations")
proc_env = {**os.environ, **(env or {}), "PATH": _augmented_path()}
+ proc_env.pop("PYTHONPATH", None)
proc = await asyncio.create_subprocess_exec(
cmd_path, *(args or []),
@@ -601,7 +605,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
data = await _recv()
tools_list = data.get("result", {}).get("tools", [])
- return [{"name": t.get("name", ""), "description": t.get("description", "")} for t in tools_list]
+ return [{"name": t.get("name", ""), "description": t.get("description", ""), "inputSchema": t.get("inputSchema")} for t in tools_list]
except HTTPException:
raise
@@ -616,12 +620,34 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
proc.terminate()
await asyncio.wait_for(proc.wait(), timeout=5.0)
except Exception:
- proc.kill()
+ try:
+ proc.kill()
+ except Exception:
+ pass
@tools_lib.router.post("/{tool_id}/discover")
async def discover_tools(tool_id: str):
tool = _load(tool_id)
+
+ if tool.auth_type == "oauth2" and tool.auth_status == "connected":
+ refreshed = await refresh_google_token(tool)
+ if not refreshed and tool.oauth_tokens.get("access_token"):
+ expiry = tool.oauth_tokens.get("token_expiry", 0)
+ if time.time() >= expiry - 60:
+ client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
+ if not client_id:
+ raise HTTPException(
+ status_code=400,
+ detail="OAuth token expired and GOOGLE_OAUTH_CLIENT_ID is not set. "
+ "In the packaged app, create ~/.openswarm.env or "
+ "~/Library/Application Support/OpenSwarm/.env with your Google OAuth credentials.",
+ )
+ raise HTTPException(
+ status_code=502,
+ detail="OAuth token expired and refresh failed. Try reconnecting Google.",
+ )
+
config = derive_mcp_config(tool)
if not config:
raise HTTPException(status_code=400, detail="Cannot derive MCP config for tool")
@@ -655,8 +681,11 @@ async def discover_tools(tool_id: str):
except HTTPException:
raise
except Exception as e:
- logger.warning(f"MCP tool discovery failed for {tool.name}: {e}")
- raise HTTPException(status_code=502, detail=f"Discovery failed: {e}")
+ msg = str(e).strip()
+ if not msg:
+ msg = type(e).__name__
+ logger.warning(f"MCP tool discovery failed for {tool.name}: {msg}", exc_info=True)
+ raise HTTPException(status_code=502, detail=f"Discovery failed: {msg}")
services: dict[str, dict[str, list[str]]] = {}
service_groups: dict[str, list[str]] = {}
@@ -681,6 +710,7 @@ async def discover_tools(tool_id: str):
permissions["_services"] = services
permissions["_service_groups"] = service_groups
permissions["_tool_descriptions"] = {t["name"]: t["description"] for t in raw_tools}
+ permissions["_tool_schemas"] = {t["name"]: t.get("inputSchema") for t in raw_tools if t.get("inputSchema")}
tool.tool_permissions = permissions
_save(tool)
diff --git a/backend/config/paths.py b/backend/config/paths.py
index 083ebdfb..62259a68 100644
--- a/backend/config/paths.py
+++ b/backend/config/paths.py
@@ -35,5 +35,8 @@ OUTPUTS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "outputs_workspace")
SKILLS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "skills_workspace")
DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
+CHANNELS_DIR = os.path.join(DATA_ROOT, "channels")
+CHANNELS_SESSIONS_DIR = os.path.join(DATA_ROOT, "channels", "sessions")
+ANALYTICS_DIR = os.path.join(DATA_ROOT, "analytics")
BACKEND_DIR = _BACKEND_DIR
diff --git a/backend/main.py b/backend/main.py
index d7f30cee..c07fd7c2 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,8 +1,14 @@
+import logging
import os
from uuid import uuid4
-from fastapi.responses import JSONResponse
+logger = logging.getLogger(__name__)
+
+from fastapi.responses import JSONResponse, HTMLResponse
from fastapi import Request
+
+# In-memory store for pending OAuth flows (state → {provider, code_verifier, redirect_uri})
+_pending_oauth: dict[str, dict] = {}
from backend.config.Apps import MainApp
from backend.apps.health.health import health
from backend.apps.agents.agents import agents
@@ -16,11 +22,14 @@ from backend.apps.mcp_registry.mcp_registry import mcp_registry
from backend.apps.skill_registry.skill_registry import skill_registry
from backend.apps.outputs.outputs import outputs
from backend.apps.dashboards.dashboards import dashboards
+from backend.apps.channels.channels import channels
+from backend.apps.analytics.analytics import analytics
+from backend.apps.auth.auth import auth
from fastapi.middleware.cors import CORSMiddleware
from fastapi import WebSocket, WebSocketDisconnect
import json
-main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards])
+main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, channels, analytics, auth])
app = main_app.app
app.add_middleware(
@@ -48,6 +57,7 @@ async def websocket_session(websocket: WebSocket, session_id: str):
payload.get("prompt", ""),
mode=payload.get("mode"),
model=payload.get("model"),
+ provider=payload.get("provider"),
images=payload.get("images"),
)
elif event == "agent:approval_response":
@@ -96,6 +106,12 @@ async def websocket_dashboard(websocket: WebSocket):
ws_manager.disconnect_global(websocket)
+@app.websocket("/ws/talk/{session_id}")
+async def websocket_talk_mode(websocket: WebSocket, session_id: str):
+ from backend.apps.channels.talk_mode import handle_talk_session
+ await handle_talk_session(websocket, session_id)
+
+
@app.post("/api/browser/command")
async def browser_command(request: Request):
"""HTTP endpoint called by the browser MCP server subprocess.
@@ -114,6 +130,53 @@ async def browser_command(request: Request):
return JSONResponse(result)
+@app.get("/api/subscriptions/pending/{state}")
+async def subscriptions_pending(state: str):
+ """Return pending OAuth data for a state param. Called by 9Router's callback page."""
+ pending = _pending_oauth.get(state)
+ if not pending:
+ return JSONResponse({"error": "not found"}, status_code=404,
+ headers={"Access-Control-Allow-Origin": "*"})
+ return JSONResponse({
+ "provider": pending["provider"],
+ "code_verifier": pending["code_verifier"],
+ "redirect_uri": pending["redirect_uri"],
+ }, headers={"Access-Control-Allow-Origin": "*"})
+
+
+@app.get("/api/subscriptions/callback")
+async def subscriptions_callback(request: Request):
+ """Catch OAuth redirect from provider, exchange code via 9Router, close window."""
+ code = request.query_params.get("code", "")
+ state = request.query_params.get("state", "")
+ error = request.query_params.get("error", "")
+
+ if error:
+ desc = request.query_params.get("error_description", error)
+ return HTMLResponse(f'Authorization failed
{desc}
')
+
+ pending = _pending_oauth.pop(state, None)
+ if not pending:
+ return HTMLResponse('Session expired
Please try connecting again.
')
+
+ from backend.apps.nine_router import exchange_oauth
+ try:
+ await exchange_oauth(pending["provider"], code, pending["redirect_uri"], pending["code_verifier"], state)
+ except Exception as e:
+ return HTMLResponse(f'')
+
+ return HTMLResponse(
+ ''
+ ''
+ '
✓
'
+ '
Connected!
'
+ '
You can close this window
'
+ '
'
+ ''
+ ''
+ )
+
+
@app.post("/api/browser-agent/run")
async def browser_agent_run(request: Request):
"""Run one or more browser sub-agents in parallel.
@@ -126,24 +189,72 @@ async def browser_agent_run(request: Request):
model = body.get("model", "sonnet")
dashboard_id = body.get("dashboard_id", "")
pre_selected_browser_ids = body.get("pre_selected_browser_ids", [])
+ parent_session_id = body.get("parent_session_id", "")
if not tasks:
return JSONResponse({"error": "tasks array is required"}, status_code=400)
settings = load_settings()
- if not settings.anthropic_api_key:
- return JSONResponse({"error": "Anthropic API key not configured"}, status_code=400)
+
+ # Determine API credentials — check API key, then 9Router
+ api_key = settings.anthropic_api_key
+ auth_token = None
+ base_url = None
+
+ if not api_key:
+ # Try 9Router
+ from backend.apps.nine_router import is_running as _9r_running
+ if _9r_running():
+ api_key = "9router"
+ base_url = "http://localhost:20128/v1"
+ auth_token = None
+ else:
+ return JSONResponse({"error": "No AI provider configured. Set an API key or connect a subscription."}, status_code=400)
results = await run_browser_agents(
tasks=tasks,
model=model,
- api_key=settings.anthropic_api_key,
+ api_key=api_key,
dashboard_id=dashboard_id or None,
pre_selected_browser_ids=pre_selected_browser_ids,
+ parent_session_id=parent_session_id or None,
+ auth_token=auth_token,
+ base_url=base_url,
)
return JSONResponse({"results": results})
+@app.post("/api/invoke-agent/run")
+async def invoke_agent_run(request: Request):
+ """Fork an existing agent session and send it a new message.
+ Called by the invoke_agent_mcp_server stdio subprocess."""
+ body = await request.json()
+ session_id = body.get("session_id", "")
+ message = body.get("message", "")
+ parent_session_id = body.get("parent_session_id", "")
+ dashboard_id = body.get("dashboard_id", "")
+
+ if not session_id:
+ return JSONResponse({"error": "session_id is required"}, status_code=400)
+ if not message:
+ return JSONResponse({"error": "message is required"}, status_code=400)
+
+ try:
+ from backend.apps.agents.agent_manager import agent_manager
+ result = await agent_manager.invoke_agent(
+ source_session_id=session_id,
+ message=message,
+ parent_session_id=parent_session_id or None,
+ dashboard_id=dashboard_id or None,
+ )
+ return JSONResponse(result)
+ except ValueError as e:
+ return JSONResponse({"error": str(e)}, status_code=404)
+ except Exception as e:
+ logger.exception("invoke_agent_run failed")
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
if __name__ == "__main__":
import argparse
import uvicorn
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 8ef474e6..c9792e56 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,5 +1,8 @@
anthropic
-claude-agent-sdk
+openai
+google-genai
+mcp
+posthog
jsonschema
fastapi[standard]
pydantic==2.10.5
@@ -9,4 +12,11 @@ pytest==8.3.4
pytest-asyncio==0.25.2
typeguard==4.4.2
python-dotenv==1.1.1
-Pillow
\ No newline at end of file
+Pillow
+# Channels: SMS, WhatsApp, Voice
+twilio>=9.0.0
+telnyx>=2.0.0
+edge-tts>=6.1.0
+httpx>=0.27.0
+playwright
+cryptography>=42.0.0
\ No newline at end of file
diff --git a/electron/main.js b/electron/main.js
index d5f79a99..c209718e 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -1,5 +1,6 @@
-const { app, BrowserWindow, ipcMain, shell } = require('electron');
-const { autoUpdater } = require('electron-updater');
+const { app, components, BrowserWindow, ipcMain, shell, session } = require('electron');
+let autoUpdater;
+try { autoUpdater = require('electron-updater').autoUpdater; } catch (_) {}
const path = require('path');
const { spawn, execFileSync } = require('child_process');
const os = require('os');
@@ -7,9 +8,16 @@ const fs = require('fs');
const getPort = require('get-port');
const http = require('http');
+app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling');
+app.commandLine.appendSwitch('ignore-gpu-blocklist');
+app.commandLine.appendSwitch('enable-gpu-rasterization');
+app.commandLine.appendSwitch('enable-zero-copy');
+app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
+
let mainWindow = null;
let backendProcess = null;
let backendPort = null;
+let cachedUpdateStatus = { status: 'idle', info: null, error: null };
const isPackaged = app.isPackaged;
const isDev = process.env.ELECTRON_DEV === '1';
@@ -24,9 +32,10 @@ const iconPath = path.join(__dirname, 'build', 'icon.png');
function getShellPath() {
if (process.platform !== 'darwin' || isDev) return process.env.PATH || '';
+ // Strategy 1: ask the user's login shell for its PATH
try {
- const shell = process.env.SHELL || '/bin/zsh';
- const result = execFileSync(shell, ['-ilc', 'echo $PATH'], {
+ const userShell = process.env.SHELL || '/bin/zsh';
+ const result = execFileSync(userShell, ['-ilc', 'echo $PATH'], {
encoding: 'utf8',
timeout: 5000,
env: { ...process.env, HOME: os.homedir() },
@@ -35,19 +44,40 @@ function getShellPath() {
if (resolved) return resolved;
} catch (_) { /* fall through */ }
+ // Strategy 2: read macOS system PATH config (/etc/paths + /etc/paths.d/*)
+ const systemPaths = [];
+ try {
+ const base = fs.readFileSync('/etc/paths', 'utf8');
+ for (const line of base.split('\n')) {
+ const p = line.trim();
+ if (p) systemPaths.push(p);
+ }
+ } catch (_) { /* ignore */ }
+ try {
+ const pathsD = '/etc/paths.d';
+ if (fs.existsSync(pathsD)) {
+ for (const file of fs.readdirSync(pathsD).sort()) {
+ const content = fs.readFileSync(path.join(pathsD, file), 'utf8');
+ for (const line of content.split('\n')) {
+ const p = line.trim();
+ if (p) systemPaths.push(p);
+ }
+ }
+ }
+ } catch (_) { /* ignore */ }
+
+ // Strategy 3: well-known user-local bin directories
const home = os.homedir();
const fallbackDirs = [
- path.join(home, '.nvm/versions/node'),
+ path.join(home, '.local/bin'),
path.join(home, '.volta/bin'),
path.join(home, '.fnm/aliases/default/bin'),
path.join(home, '.bun/bin'),
path.join(home, '.cargo/bin'),
- path.join(home, '.local/bin'),
'/opt/homebrew/bin',
'/usr/local/bin',
];
- // For nvm, resolve the current default version dynamically
const nvmDir = path.join(home, '.nvm/versions/node');
try {
if (fs.existsSync(nvmDir)) {
@@ -58,10 +88,14 @@ function getShellPath() {
}
} catch (_) { /* ignore */ }
- const existing = fallbackDirs.filter((d) => {
- try { return fs.statSync(d).isDirectory(); } catch { return false; }
- });
- return [...existing, process.env.PATH || ''].join(':');
+ const seen = new Set();
+ const dirs = [];
+ for (const d of [...fallbackDirs, ...systemPaths, ...(process.env.PATH || '').split(':')]) {
+ if (!d || seen.has(d)) continue;
+ seen.add(d);
+ try { if (fs.statSync(d).isDirectory()) dirs.push(d); } catch { /* skip */ }
+ }
+ return dirs.join(':');
}
function getResourcePath(...segments) {
@@ -190,6 +224,18 @@ function createWindow() {
mainWindow.loadFile(frontendPath);
}
+ mainWindow.webContents.on('will-attach-webview', (_event, webPreferences, _params) => {
+ webPreferences.plugins = true;
+ webPreferences.enableBlinkFeatures = 'EncryptedMedia';
+ });
+
+ mainWindow.webContents.on('will-navigate', (event, url) => {
+ if (isDev && url.startsWith('http://localhost:3000')) return;
+ if (url.startsWith('file://')) return;
+ event.preventDefault();
+ mainWindow.webContents.send('webview-new-window', url, mainWindow.webContents.id);
+ });
+
mainWindow.on('closed', () => {
mainWindow = null;
});
@@ -202,30 +248,36 @@ function sendToRenderer(channel, ...args) {
}
function setupAutoUpdater() {
+ if (!autoUpdater) return;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.on('update-available', (info) => {
console.log(`Update available: ${info.version}`);
+ cachedUpdateStatus = { status: 'available', info, error: null };
sendToRenderer('update-available', info);
});
autoUpdater.on('update-not-available', (info) => {
console.log('App is up to date');
+ cachedUpdateStatus = { status: 'not-available', info, error: null };
sendToRenderer('update-not-available', info);
});
autoUpdater.on('download-progress', (progress) => {
+ cachedUpdateStatus = { status: 'downloading', info: progress, error: null };
sendToRenderer('download-progress', progress);
});
autoUpdater.on('update-downloaded', (info) => {
console.log(`Update downloaded: ${info.version}`);
+ cachedUpdateStatus = { status: 'downloaded', info, error: null };
sendToRenderer('update-downloaded', info);
});
autoUpdater.on('error', (err) => {
console.error('Auto-update error:', err);
+ cachedUpdateStatus = { status: 'error', info: null, error: err?.message || String(err) };
sendToRenderer('update-error', err?.message || String(err));
});
@@ -252,6 +304,68 @@ app.whenReady().then(async () => {
try { app.dock.setIcon(iconPath); } catch (_) {}
}
+ session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
+ const allowed = [
+ 'media', 'mediaKeySystem', 'protected-media-identifier',
+ 'geolocation', 'notifications', 'midi', 'midiSysex',
+ 'clipboard-read', 'clipboard-sanitized-write',
+ 'pointerLock', 'fullscreen', 'idle-detection',
+ ];
+ console.log('Permission request:', permission, '->', allowed.includes(permission) ? 'granted' : 'denied');
+ callback(allowed.includes(permission));
+ });
+ session.defaultSession.setPermissionCheckHandler((_wc, permission) => {
+ const allowed = [
+ 'media', 'mediaKeySystem', 'protected-media-identifier',
+ 'clipboard-read', 'clipboard-sanitized-write',
+ 'pointerLock', 'fullscreen', 'idle-detection',
+ ];
+ return allowed.includes(permission);
+ });
+
+ // Read-only logging for DRM license requests — no modifying interceptors
+ // so the network stack can set Content-Type and other headers normally.
+ session.defaultSession.webRequest.onSendHeaders(
+ { urls: ['*://*/*widevine*license*'] },
+ (details) => {
+ console.log(`[drm-req] ${details.method} ${details.url}`);
+ for (const [k, v] of Object.entries(details.requestHeaders || {})) {
+ if (/content-type|origin|referer|auth|accept/i.test(k)) {
+ console.log(`[drm-req] ${k}: ${v}`);
+ }
+ }
+ },
+ );
+ session.defaultSession.webRequest.onCompleted(
+ { urls: ['*://*/*widevine*', '*://*/*license*'] },
+ (details) => {
+ console.log(`[drm-net] ${details.method} ${details.url} → ${details.statusCode}`);
+ },
+ );
+ session.defaultSession.webRequest.onErrorOccurred(
+ { urls: ['*://*/*widevine*', '*://*/*license*'] },
+ (details) => {
+ console.log(`[drm-net] FAILED ${details.method} ${details.url} → ${details.error}`);
+ },
+ );
+
+ // Wait for the Widevine CDM to be downloaded/ready (CastLabs Component
+ // Updater Service). On first launch this downloads the CDM; subsequent
+ // launches use the cached version.
+ if (components && typeof components.whenReady === 'function') {
+ try {
+ await components.whenReady();
+ console.log('Widevine CDM ready');
+ if (typeof components.status === 'function') {
+ console.log('CDM component status:', JSON.stringify(components.status()));
+ }
+ } catch (err) {
+ console.warn('Widevine CDM not available:', err.message);
+ }
+ } else {
+ console.log('CastLabs components API not available — using standard Electron (no DRM)');
+ }
+
try {
if (isDev) {
backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10);
@@ -262,6 +376,13 @@ app.whenReady().then(async () => {
createWindow();
if (!isDev) {
setupAutoUpdater();
+ mainWindow.webContents.on('did-finish-load', () => {
+ if (cachedUpdateStatus.status === 'available') {
+ sendToRenderer('update-available', cachedUpdateStatus.info);
+ } else if (cachedUpdateStatus.status === 'downloaded') {
+ sendToRenderer('update-downloaded', cachedUpdateStatus.info);
+ }
+ });
}
} catch (err) {
console.error('Failed to start:', err);
@@ -269,6 +390,79 @@ app.whenReady().then(async () => {
}
});
+app.on('web-contents-created', (_event, contents) => {
+ contents.setWindowOpenHandler(({ url, disposition }) => {
+ if (disposition === 'foreground-tab' || disposition === 'background-tab') {
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('webview-new-window', url, contents.id);
+ }
+ return { action: 'deny' };
+ }
+
+ return {
+ action: 'allow',
+ overrideBrowserWindowOptions: {
+ parent: mainWindow || undefined,
+ },
+ };
+ });
+
+ contents.on('did-create-window', (childWindow) => {
+ if (mainWindow && !mainWindow.isDestroyed() && !childWindow.isDestroyed()) {
+ childWindow.setParentWindow(mainWindow);
+ }
+ });
+
+ if (contents.getType() === 'webview') {
+ contents.on('console-message', (_e, level, message, line, sourceId) => {
+ if (message.includes('widevine') || message.includes('drm') ||
+ message.includes('license') || message.includes('MediaKeySession') ||
+ message.includes('EME') || message.includes('[drm-diag]') || level >= 2) {
+ const tag = ['LOG', 'INFO', 'WARN', 'ERROR'][level] || 'LOG';
+ const src = sourceId ? sourceId.split('/').pop() : '';
+ console.log(`[webview:${tag}] ${message}${src ? ` (${src}:${line})` : ''}`);
+ }
+ });
+
+ contents.on('dom-ready', () => {
+ const url = contents.getURL();
+ if (url.includes('spotify')) {
+ contents.executeJavaScript(`
+ (function() {
+ const origFetch = window.fetch;
+ window.fetch = async function(...args) {
+ const resp = await origFetch.apply(this, args);
+ const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
+ if (url.includes('widevine-license') && !resp.ok) {
+ const clone = resp.clone();
+ try {
+ const text = await clone.text();
+ console.log('[drm-diag] License response ' + resp.status + ': ' + text.substring(0, 500));
+ } catch(e) {}
+ }
+ return resp;
+ };
+
+ // Check EME availability
+ if (navigator.requestMediaKeySystemAccess) {
+ navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{
+ initDataTypes: ['cenc'],
+ audioCapabilities: [{contentType: 'audio/mp4; codecs="mp4a.40.2"'}],
+ }]).then(function(access) {
+ console.log('[drm-diag] Widevine EME access: ' + access.keySystem);
+ }).catch(function(err) {
+ console.log('[drm-diag] Widevine EME FAILED: ' + err.message);
+ });
+ } else {
+ console.log('[drm-diag] EME API not available');
+ }
+ })();
+ `).catch(() => {});
+ }
+ });
+ }
+});
+
app.on('window-all-closed', () => {
if (!isDev) killBackend();
app.quit();
@@ -286,9 +480,14 @@ app.on('activate', () => {
ipcMain.handle('get-backend-port', () => backendPort);
ipcMain.handle('get-app-version', () => app.getVersion());
+ipcMain.handle('get-webview-preload-path', () => {
+ return `file://${path.join(__dirname, 'webview-preload.js')}`;
+});
+
+ipcMain.handle('get-update-status', () => cachedUpdateStatus);
ipcMain.handle('check-for-updates', async () => {
- if (!isPackaged) {
+ if (!autoUpdater || !isPackaged) {
sendToRenderer('update-error', 'Update check is only available in the packaged app.');
return { success: false, error: 'Not packaged' };
}
@@ -305,6 +504,7 @@ ipcMain.handle('check-for-updates', async () => {
});
ipcMain.handle('download-update', async () => {
+ if (!autoUpdater) return { success: false, error: 'Updater not available' };
try {
await autoUpdater.downloadUpdate();
return { success: true };
@@ -314,6 +514,7 @@ ipcMain.handle('download-update', async () => {
});
ipcMain.handle('install-update', () => {
+ if (!autoUpdater) return;
autoUpdater.quitAndInstall(false, true);
});
diff --git a/electron/package-lock.json b/electron/package-lock.json
index 3358b1a3..b3422f6d 100644
--- a/electron/package-lock.json
+++ b/electron/package-lock.json
@@ -1,19 +1,20 @@
{
"name": "openswarm",
- "version": "1.0.3",
+ "version": "1.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
- "version": "1.0.3",
+ "version": "1.0.11",
+ "hasInstallScript": true,
"dependencies": {
"electron-updater": "^6.3.0",
"get-port": "^5.1.1"
},
"devDependencies": {
"@electron/notarize": "^3.1.1",
- "electron": "^33.0.0",
+ "electron": "castlabs/electron-releases#v33.4.11+wvcus",
"electron-builder": "^25.1.0"
}
},
@@ -2206,9 +2207,8 @@
}
},
"node_modules/electron": {
- "version": "33.4.11",
- "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz",
- "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==",
+ "version": "33.4.11+wvcus",
+ "resolved": "git+ssh://git@github.com/castlabs/electron-releases.git#d1cf58c11ec0a8a04f307ed362d7efde2816778d",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -3953,9 +3953,9 @@
}
},
"node_modules/node-abi": {
- "version": "3.88.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz",
- "integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==",
+ "version": "3.89.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
+ "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4605,9 +4605,9 @@
}
},
"node_modules/sax": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
- "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
+ "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
diff --git a/electron/package.json b/electron/package.json
index 48ee49a7..32c3c37d 100644
--- a/electron/package.json
+++ b/electron/package.json
@@ -1,11 +1,13 @@
{
"name": "openswarm",
- "version": "1.0.3",
+ "version": "1.0.12",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "ELECTRON_DEV=1 electron .",
+ "postinstall": "bash scripts/sign-vmp.sh",
+ "sign-vmp": "bash scripts/sign-vmp.sh",
"dist": "electron-builder --mac --publish never",
"dist:publish": "electron-builder --mac --publish always",
"dist:all": "electron-builder --mac --win --linux"
@@ -16,12 +18,15 @@
},
"devDependencies": {
"@electron/notarize": "^3.1.1",
- "electron": "^33.0.0",
+ "electron": "castlabs/electron-releases#v33.4.11+wvcus",
"electron-builder": "^25.1.0"
},
"build": {
"appId": "com.clusterlabs.openswarm",
"productName": "OpenSwarm",
+ "electronDownload": {
+ "mirror": "https://github.com/castlabs/electron-releases/releases/download/v"
+ },
"directories": {
"output": "dist"
},
@@ -38,6 +43,7 @@
"entitlementsInherit": "build/entitlements.mac.plist"
},
"dmg": {
+ "artifactName": "OpenSwarm-${arch}.${ext}",
"title": "OpenSwarm",
"contents": [
{
@@ -54,34 +60,24 @@
},
"extraResources": [
{
- "from": "../frontend/dist",
+ "from": "build-staging/frontend",
"to": "frontend",
"filter": [
"**/*"
]
},
{
- "from": "../backend",
+ "from": "build-staging/backend",
"to": "backend",
"filter": [
- "**/*",
- "!__pycache__/**",
- "!**/__pycache__/**",
- "!.venv/**",
- "!*.pyc"
+ "**/*"
]
},
{
- "from": "../debugger",
+ "from": "build-staging/debugger",
"to": "debugger",
"filter": [
- "**/*",
- "!__pycache__/**",
- "!**/__pycache__/**",
- "!*.pyc",
- "!.venv/**",
- "!**/.venv/**",
- "!**/node_modules/**"
+ "**/*"
]
},
{
diff --git a/electron/preload.js b/electron/preload.js
index 2f9037be..534c540f 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -2,15 +2,18 @@ const { contextBridge, ipcRenderer } = require('electron');
(async () => {
const port = await ipcRenderer.invoke('get-backend-port');
+ const webviewPreloadPath = await ipcRenderer.invoke('get-webview-preload-path');
contextBridge.exposeInMainWorld('__OPENSWARM_PORT__', port);
contextBridge.exposeInMainWorld('openswarm', {
getBackendPort: () => port,
+ getWebviewPreloadPath: () => webviewPreloadPath,
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
capturePage: (rect) => ipcRenderer.invoke('capture-page', rect),
+ getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
@@ -40,5 +43,11 @@ const { contextBridge, ipcRenderer } = require('electron');
ipcRenderer.on('update-error', listener);
return () => ipcRenderer.removeListener('update-error', listener);
},
+
+ onWebviewNewWindow: (cb) => {
+ const listener = (_event, url, webContentsId) => cb(url, webContentsId);
+ ipcRenderer.on('webview-new-window', listener);
+ return () => ipcRenderer.removeListener('webview-new-window', listener);
+ },
});
})();
diff --git a/electron/scripts/sign-vmp.sh b/electron/scripts/sign-vmp.sh
new file mode 100755
index 00000000..ba5eb833
--- /dev/null
+++ b/electron/scripts/sign-vmp.sh
@@ -0,0 +1,61 @@
+#!/bin/bash
+# Signs the CastLabs Electron binary with a production VMP certificate via EVS,
+# then repairs macOS framework symlinks that npm/signing may strip.
+#
+# First-time setup (one-time):
+# pip3 install --user castlabs-evs
+# python3 -m castlabs_evs.account signup
+#
+# After signup, this script runs automatically.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ELECTRON_DIR="$SCRIPT_DIR/../node_modules/electron/dist"
+FW_BASE="$ELECTRON_DIR/Electron.app/Contents/Frameworks"
+
+fix_framework_symlinks() {
+ [ -d "$FW_BASE" ] || return 0
+ for fw in "$FW_BASE"/*.framework; do
+ [ -d "$fw/Versions/A" ] || continue
+ local name
+ name=$(basename "$fw" .framework)
+ cd "$fw"
+ (cd Versions && ln -sf A Current 2>/dev/null)
+ ln -sf "Versions/Current/$name" "$name" 2>/dev/null
+ [ -d "Versions/A/Resources" ] && ln -sf Versions/Current/Resources Resources 2>/dev/null
+ [ -d "Versions/A/Libraries" ] && ln -sf Versions/Current/Libraries Libraries 2>/dev/null
+ [ -d "Versions/A/Helpers" ] && ln -sf Versions/Current/Helpers Helpers 2>/dev/null
+ done
+}
+
+if [ ! -d "$ELECTRON_DIR" ]; then
+ echo "[vmp] Electron dist not found at $ELECTRON_DIR — skipping VMP signing"
+ fix_framework_symlinks
+ exit 0
+fi
+
+# Always fix symlinks first (npm git installs strip them)
+fix_framework_symlinks
+
+if ! python3 -c "import castlabs_evs" 2>/dev/null; then
+ echo "[vmp] castlabs-evs not installed. Install with: pip3 install --user castlabs-evs"
+ echo "[vmp] Skipping VMP signing — DRM playback will be limited"
+ exit 0
+fi
+
+VERIFY_OUTPUT=$(python3 -m castlabs_evs.vmp verify-pkg "$ELECTRON_DIR" 2>&1)
+if echo "$VERIFY_OUTPUT" | grep -q "Signature is valid" && ! echo "$VERIFY_OUTPUT" | grep -q "development only"; then
+ echo "[vmp] Electron already has a valid production VMP signature"
+ exit 0
+fi
+
+echo "[vmp] Signing Electron with production VMP certificate..."
+if python3 -m castlabs_evs.vmp sign-pkg "$ELECTRON_DIR" 2>&1; then
+ echo "[vmp] VMP signing successful — full DRM playback enabled"
+ # Re-fix symlinks in case signing modified the bundle
+ fix_framework_symlinks
+else
+ echo "[vmp] VMP signing failed — you may need to run: python3 -m castlabs_evs.account signup"
+ echo "[vmp] DRM playback will be limited to previews until signed"
+fi
+
+exit 0
diff --git a/electron/webview-preload.js b/electron/webview-preload.js
new file mode 100644
index 00000000..95a93d66
--- /dev/null
+++ b/electron/webview-preload.js
@@ -0,0 +1,87 @@
+/**
+ * Webview preload script — patches browser fingerprinting so sites like
+ * Spotify/Netflix don't detect an Electron shell and disable features.
+ * Loaded via the webview's `preload` attribute before any page script runs.
+ */
+
+'use strict';
+
+// Hide webdriver flag
+Object.defineProperty(navigator, 'webdriver', {
+ get: () => false,
+ configurable: true,
+});
+
+// Spoof navigator.plugins (Chrome has a few built-in ones)
+const fakePlugins = {
+ 0: { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
+ 1: { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
+ 2: { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
+ length: 3,
+ item: (i) => fakePlugins[i] || null,
+ namedItem: (name) => {
+ for (let i = 0; i < fakePlugins.length; i++) {
+ if (fakePlugins[i].name === name) return fakePlugins[i];
+ }
+ return null;
+ },
+ refresh: () => {},
+ [Symbol.iterator]: function* () {
+ for (let i = 0; i < this.length; i++) yield this[i];
+ },
+};
+try {
+ Object.defineProperty(navigator, 'plugins', {
+ get: () => fakePlugins,
+ configurable: true,
+ });
+} catch (_) {}
+
+// Ensure window.chrome exists (sites test for it)
+if (!window.chrome) {
+ window.chrome = {};
+}
+if (!window.chrome.runtime) {
+ window.chrome.runtime = {
+ connect: () => {},
+ sendMessage: () => {},
+ onMessage: { addListener: () => {}, removeListener: () => {} },
+ };
+}
+
+// Ensure navigator.languages has sensible values
+try {
+ Object.defineProperty(navigator, 'languages', {
+ get: () => ['en-US', 'en'],
+ configurable: true,
+ });
+} catch (_) {}
+
+// Patch permissions.query to report 'granted' for common permissions
+const originalQuery = navigator.permissions?.query?.bind(navigator.permissions);
+if (originalQuery) {
+ navigator.permissions.query = (params) => {
+ if (params.name === 'notifications') {
+ return Promise.resolve({ state: 'granted', onchange: null });
+ }
+ return originalQuery(params).catch(() =>
+ Promise.resolve({ state: 'prompt', onchange: null })
+ );
+ };
+}
+
+// Prevent iframe detection heuristics
+try {
+ Object.defineProperty(document, 'hidden', {
+ get: () => false,
+ configurable: true,
+ });
+ Object.defineProperty(document, 'visibilityState', {
+ get: () => 'visible',
+ configurable: true,
+ });
+} catch (_) {}
+
+// Fix console.debug detection (some sites use it as a breakpoint detector)
+const noop = () => {};
+if (!window.console.debug) window.console.debug = noop;
diff --git a/face.html b/face.html
new file mode 100644
index 00000000..23f44491
--- /dev/null
+++ b/face.html
@@ -0,0 +1,334 @@
+
+
+
+
+
+Pixel Face
+
+
+
+
+
+
+
diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index 7628f5f0..81c973d1 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -3,8 +3,9 @@ import { Provider } from 'react-redux';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mui/material';
import { store } from '../shared/state/store';
-import { useAppDispatch } from '@/shared/hooks';
+import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchSettings } from '@/shared/state/settingsSlice';
+import { fetchModels } from '@/shared/state/modelsSlice';
import {
setAppVersion,
setUpdateAvailable,
@@ -22,9 +23,13 @@ import Tools from './pages/Tools/Tools';
import Modes from './pages/Modes/Modes';
import Views from './pages/Views/Views';
import Customization from './pages/Customization/Customization';
+import Channels from './pages/Channels/Channels';
+import Analytics from './pages/Analytics/Analytics';
+import AnalyticsOptIn from './components/AnalyticsOptIn';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
+import OnboardingModal from './components/OnboardingModal';
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
function buildMuiTheme(c: ClaudeTokens, mode: 'light' | 'dark') {
@@ -155,9 +160,16 @@ const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }
const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
+ const { setMode: setThemeMode } = useThemeMode();
+ const theme = useAppSelector((s) => s.settings.data.theme);
+ const loaded = useAppSelector((s) => s.settings.loaded);
useEffect(() => {
dispatch(fetchSettings());
+ dispatch(fetchModels());
}, [dispatch]);
+ useEffect(() => {
+ if (loaded) setThemeMode(theme as 'light' | 'dark');
+ }, [loaded, theme, setThemeMode]);
return <>{children}>;
};
@@ -170,6 +182,21 @@ const UpdateListener: React.FC<{ children: React.ReactNode }> = ({ children }) =
api.getAppVersion().then((v: string) => dispatch(setAppVersion(v)));
+ api.getUpdateStatus?.().then((cached) => {
+ if (!cached) return;
+ if (cached.status === 'available' && cached.info?.version) {
+ dispatch(setUpdateAvailable(cached.info.version));
+ } else if (cached.status === 'not-available') {
+ dispatch(setUpdateNotAvailable());
+ } else if (cached.status === 'downloading' && cached.info?.percent != null) {
+ dispatch(setDownloading(cached.info.percent));
+ } else if (cached.status === 'downloaded') {
+ dispatch(setUpdateDownloaded());
+ } else if (cached.status === 'error' && cached.error) {
+ dispatch(setUpdateError(cached.error));
+ }
+ });
+
const cleanups = [
api.onUpdateAvailable?.((info: OpenSwarmUpdateInfo) => dispatch(setUpdateAvailable(info.version))),
api.onUpdateNotAvailable?.(() => dispatch(setUpdateNotAvailable())),
@@ -207,8 +234,12 @@ const ThemedApp: React.FC = () => {
} />
} />
} />
+ } />
+ } />
+
+
diff --git a/frontend/src/app/components/AnalyticsOptIn.tsx b/frontend/src/app/components/AnalyticsOptIn.tsx
new file mode 100644
index 00000000..b96701dd
--- /dev/null
+++ b/frontend/src/app/components/AnalyticsOptIn.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import Paper from '@mui/material/Paper';
+import { useAppDispatch, useAppSelector } from '@/shared/hooks';
+import { updateSettings } from '@/shared/state/settingsSlice';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+const AnalyticsOptIn: React.FC = () => {
+ const c = useClaudeTokens();
+ const dispatch = useAppDispatch();
+ const settings = useAppSelector((s) => s.settings.data);
+ const loaded = useAppSelector((s) => s.settings.loaded);
+
+ if (!loaded || settings.analytics_opt_in !== null) return null;
+
+ const handleChoice = (optIn: boolean) => {
+ dispatch(updateSettings({ ...settings, analytics_opt_in: optIn }));
+ };
+
+ return (
+
+
+
+ Help improve OpenSwarm
+
+
+ Share anonymous usage statistics like session counts, feature usage, and model preferences.
+ No conversations, file paths, or personal information — ever.
+ You can change this anytime in Settings.
+
+
+
+
+
+
+
+ );
+};
+
+export default AnalyticsOptIn;
diff --git a/frontend/src/app/components/DynamicIsland.tsx b/frontend/src/app/components/DynamicIsland.tsx
new file mode 100644
index 00000000..76149127
--- /dev/null
+++ b/frontend/src/app/components/DynamicIsland.tsx
@@ -0,0 +1,993 @@
+import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import IconButton from '@mui/material/IconButton';
+import Tooltip from '@mui/material/Tooltip';
+import Collapse from '@mui/material/Collapse';
+import SearchIcon from '@mui/icons-material/Search';
+import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined';
+import CloseIcon from '@mui/icons-material/Close';
+import CheckIcon from '@mui/icons-material/Check';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import ExpandLessIcon from '@mui/icons-material/ExpandLess';
+import { motion, AnimatePresence } from 'framer-motion';
+import { useNavigate } from 'react-router-dom';
+import { useAppDispatch, useAppSelector } from '@/shared/hooks';
+import {
+ handleApproval,
+ stopAgent,
+ dismissAgentNotification,
+ dismissAllFinishedNotifications,
+ ApprovalRequest,
+ AgentSession,
+ HistorySession,
+} from '@/shared/state/agentsSlice';
+import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
+import ApprovalBar, { BatchApprovalBar, parseMcpToolName, useMcpToolMeta, getToolIcon } from '@/app/pages/AgentChat/ApprovalBar';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+type IslandState = 'idle' | 'compact' | 'compact-actionable' | 'expanded';
+
+interface SessionApprovalGroup {
+ sessionId: string;
+ sessionName: string;
+ approvals: ApprovalRequest[];
+}
+
+type TrackedAgent = {
+ id: string;
+ name: string;
+ status: AgentSession['status'] | string;
+ dashboardId?: string;
+};
+
+const STATUS_CONFIG: Record = {
+ running: { label: 'Running', tokenKey: 'success' },
+ waiting_approval: { label: 'Waiting', tokenKey: 'warning' },
+ completed: { label: 'Done', tokenKey: 'success' },
+ error: { label: 'Error', tokenKey: 'error' },
+ stopped: { label: 'Stopped', tokenKey: 'info' },
+};
+
+// ---------------------------------------------------------------------------
+// Spring configs
+// ---------------------------------------------------------------------------
+
+const SPRING_LAYOUT = { type: 'spring' as const, stiffness: 400, damping: 30 };
+const SPRING_BOUNCE = { type: 'spring' as const, stiffness: 500, damping: 25 };
+
+// ---------------------------------------------------------------------------
+// Sub-components
+// ---------------------------------------------------------------------------
+
+const StatusDot: React.FC<{ status: string; c: ReturnType }> = ({ status, c }) => {
+ const cfg = STATUS_CONFIG[status];
+ const color = cfg?.tokenKey ? (c.status as any)[cfg.tokenKey] : c.text.ghost;
+ const isActive = status === 'running';
+ return (
+
+ );
+};
+
+const AgentStatusRow: React.FC<{
+ agent: TrackedAgent;
+ c: ReturnType;
+ onStop: (id: string) => void;
+ onDismiss: (id: string) => void;
+ onNavigate: (dashboardId: string, agentId: string) => void;
+}> = ({ agent, c, onStop, onDismiss, onNavigate }) => {
+ const isActive = agent.status === 'running' || agent.status === 'waiting_approval';
+ const cfg = STATUS_CONFIG[agent.status] ?? { label: agent.status };
+
+ return (
+ agent.dashboardId && onNavigate(agent.dashboardId, agent.id)}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 1,
+ px: 2,
+ py: 0.75,
+ cursor: agent.dashboardId ? 'pointer' : 'default',
+ '&:hover': { bgcolor: c.border.subtle },
+ transition: 'background-color 0.15s',
+ minHeight: 34,
+ }}
+ >
+
+
+ {agent.name}
+
+
+ {cfg.label}
+
+ {isActive ? (
+
+ { e.stopPropagation(); onStop(agent.id); }}
+ sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.border.subtle } }}
+ >
+
+
+
+ ) : (
+
+ { e.stopPropagation(); onDismiss(agent.id); }}
+ sx={{ p: 0.25, color: c.text.ghost, '&:hover': { bgcolor: c.border.subtle } }}
+ >
+
+
+
+ )}
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Compact activity indicator — subtle breathing dot
+// ---------------------------------------------------------------------------
+
+const ActivityIndicator: React.FC<{ c: ReturnType }> = ({ c }) => (
+
+);
+
+// ---------------------------------------------------------------------------
+// Main component
+// ---------------------------------------------------------------------------
+
+const DynamicIsland: React.FC = () => {
+ const c = useClaudeTokens();
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+ const islandRef = useRef(null);
+
+ const sessions = useAppSelector((state) => state.agents.sessions);
+ const history = useAppSelector((state) => state.agents.history);
+ const trackedIds = useAppSelector((state) => state.agents.trackedNotificationIds);
+
+ const [userExpanded, setUserExpanded] = useState(false);
+
+ // ---- Derived data ----
+
+ const groups: SessionApprovalGroup[] = useMemo(() => {
+ const result: SessionApprovalGroup[] = [];
+ for (const [sessionId, session] of Object.entries(sessions)) {
+ if (session.pending_approvals?.length > 0) {
+ result.push({
+ sessionId,
+ sessionName: session.name || 'Agent',
+ approvals: session.pending_approvals,
+ });
+ }
+ }
+ return result;
+ }, [sessions]);
+
+ const totalApprovals = useMemo(
+ () => groups.reduce((sum, g) => sum + g.approvals.length, 0),
+ [groups],
+ );
+
+ const trackedAgents: TrackedAgent[] = useMemo(() => {
+ const agents = trackedIds
+ .map((id): TrackedAgent | null => {
+ const session = sessions[id];
+ if (session && session.status !== 'draft') {
+ return { id, name: session.name, status: session.status, dashboardId: session.dashboard_id };
+ }
+ const hist: HistorySession | undefined = history[id];
+ if (hist) {
+ return { id, name: hist.name, status: hist.status, dashboardId: hist.dashboard_id };
+ }
+ return null;
+ })
+ .filter((a): a is TrackedAgent => a !== null);
+
+ const trackedIdSet = new Set(trackedIds);
+ for (const g of groups) {
+ if (!trackedIdSet.has(g.sessionId)) {
+ const session = sessions[g.sessionId];
+ if (session && session.status !== 'draft') {
+ agents.push({ id: g.sessionId, name: session.name, status: session.status, dashboardId: session.dashboard_id });
+ }
+ }
+ }
+
+ return agents;
+ }, [trackedIds, sessions, history, groups]);
+
+ const activeAgents = useMemo(
+ () => trackedAgents.filter((a) => a.status === 'running' || a.status === 'waiting_approval'),
+ [trackedAgents],
+ );
+ const finishedAgents = useMemo(
+ () => trackedAgents.filter((a) => a.status !== 'running' && a.status !== 'waiting_approval'),
+ [trackedAgents],
+ );
+
+ const hasApprovals = totalApprovals > 0;
+ const hasAgents = trackedAgents.length > 0;
+
+ const hasOnlyQuestionApprovals = useMemo(() => {
+ if (!hasApprovals) return false;
+ const allApprovals = groups.flatMap((g) => g.approvals);
+ return allApprovals.every((a) => a.tool_name === 'AskUserQuestion');
+ }, [hasApprovals, groups]);
+
+ const nonQuestionApprovalCount = useMemo(
+ () => groups.reduce((sum, g) => sum + g.approvals.filter((a) => a.tool_name !== 'AskUserQuestion').length, 0),
+ [groups],
+ );
+
+ const oldestNonQuestionApproval = useMemo(() => {
+ const all = groups
+ .flatMap((g) => g.approvals)
+ .filter((a) => a.tool_name !== 'AskUserQuestion');
+ if (all.length === 0) return null;
+ return all.reduce((oldest, a) =>
+ a.created_at < oldest.created_at ? a : oldest,
+ );
+ }, [groups]);
+
+ // ---- Island state machine ----
+
+ const islandState: IslandState = useMemo(() => {
+ if (userExpanded && (hasAgents || hasApprovals)) return 'expanded';
+ if (hasApprovals && hasOnlyQuestionApprovals) return 'expanded';
+ if (hasApprovals) return 'compact-actionable';
+ if (hasAgents) return 'compact';
+ return 'idle';
+ }, [hasApprovals, hasOnlyQuestionApprovals, userExpanded, hasAgents]);
+
+ useEffect(() => {
+ if (!hasAgents && !hasApprovals) {
+ setUserExpanded(false);
+ }
+ }, [hasAgents, hasApprovals]);
+
+ // ---- Click outside to collapse ----
+
+ useEffect(() => {
+ if (islandState !== 'expanded') return;
+ const handler = (e: MouseEvent) => {
+ if (islandRef.current && !islandRef.current.contains(e.target as Node)) {
+ setUserExpanded(false);
+ }
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, [islandState]);
+
+ // ---- Callbacks ----
+
+ const onApprove = useCallback(
+ (requestId: string, updatedInput?: Record) => {
+ dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput }));
+ },
+ [dispatch],
+ );
+
+ const onDeny = useCallback(
+ (requestId: string, message?: string) => {
+ dispatch(handleApproval({ requestId, behavior: 'deny', message }));
+ },
+ [dispatch],
+ );
+
+ const onStopAgent = useCallback(
+ (sessionId: string) => dispatch(stopAgent({ sessionId })),
+ [dispatch],
+ );
+
+ const onDismissAgent = useCallback(
+ (sessionId: string) => dispatch(dismissAgentNotification(sessionId)),
+ [dispatch],
+ );
+
+ const onNavigateToDashboard = useCallback(
+ (dashboardId: string, agentId: string) => {
+ dispatch(setPendingFocusAgentId(agentId));
+ navigate(`/dashboard/${dashboardId}`);
+ },
+ [navigate, dispatch],
+ );
+
+ const onApproveAllNonQuestion = useCallback(() => {
+ for (const g of groups) {
+ for (const req of g.approvals) {
+ if (req.tool_name !== 'AskUserQuestion') {
+ dispatch(handleApproval({ requestId: req.id, behavior: 'allow' }));
+ }
+ }
+ }
+ }, [dispatch, groups]);
+
+ const onDenyAllNonQuestion = useCallback(() => {
+ for (const g of groups) {
+ for (const req of g.approvals) {
+ if (req.tool_name !== 'AskUserQuestion') {
+ dispatch(handleApproval({ requestId: req.id, behavior: 'deny' }));
+ }
+ }
+ }
+ }, [dispatch, groups]);
+
+ const onClearAllFinished = useCallback(() => {
+ dispatch(dismissAllFinishedNotifications());
+ }, [dispatch]);
+
+ const handleIslandClick = useCallback(() => {
+ if (islandState === 'compact' || islandState === 'compact-actionable') {
+ setUserExpanded(true);
+ } else if (islandState === 'expanded') {
+ setUserExpanded(false);
+ }
+ }, [islandState]);
+
+ // ---- Styling — uses the same neutral palette as the rest of the UI ----
+
+ const islandWidth = islandState === 'idle'
+ ? 200
+ : islandState === 'compact'
+ ? 210
+ : islandState === 'compact-actionable'
+ ? 310
+ : 400;
+
+ const islandBorderRadius = islandState === 'expanded' ? 14 : 50;
+
+ const shadow = islandState === 'idle'
+ ? 'none'
+ : islandState === 'compact'
+ ? c.shadow.sm
+ : c.shadow.md;
+
+ // ---- Compact summary text ----
+
+ const compactText = useMemo(() => {
+ const parts: string[] = [];
+ if (activeAgents.length > 0) {
+ parts.push(`${activeAgents.length} running`);
+ }
+ if (finishedAgents.length > 0) {
+ parts.push(`${finishedAgents.length} done`);
+ }
+ return parts.join(' · ') || 'Agents';
+ }, [activeAgents.length, finishedAgents.length]);
+
+ const glowKeyframes = useMemo(() => `
+ @keyframes approvalGlow {
+ 0%, 100% { box-shadow: 0 0 6px 1px ${c.status.warning}30; }
+ 50% { box-shadow: 0 0 12px 3px ${c.status.warning}60; }
+ }
+ `, [c.status.warning]);
+
+ // ---- Render ----
+
+ return (
+ <>
+ {islandState === 'compact-actionable' && }
+
+
+
+ {islandState === 'idle' && (
+
+ )}
+ {islandState === 'compact' && (
+
+ )}
+ {islandState === 'compact-actionable' && oldestNonQuestionApproval && (
+ setUserExpanded(true)}
+ />
+ )}
+ {islandState === 'expanded' && (
+ setUserExpanded(false)}
+ />
+ )}
+
+
+
+ >
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Idle pill — disabled search bar
+// ---------------------------------------------------------------------------
+
+const IdlePill: React.FC<{ c: ReturnType }> = ({ c }) => (
+
+
+
+
+
+ Search...
+
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Compact pill
+// ---------------------------------------------------------------------------
+
+const CompactPill: React.FC<{
+ c: ReturnType;
+ text: string;
+ activeCount: number;
+ hasApprovals: boolean;
+}> = ({ c, text, activeCount, hasApprovals }) => (
+
+
+
+
+ {text}
+
+ {hasApprovals && (
+
+ )}
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Compact-actionable pill — single approval with icon + name + approve/deny
+// ---------------------------------------------------------------------------
+
+const CompactActionablePill: React.FC<{
+ c: ReturnType;
+ request: ApprovalRequest;
+ remainingCount: number;
+ onApprove: (requestId: string) => void;
+ onDeny: (requestId: string) => void;
+ onExpand: () => void;
+}> = ({ c, request, remainingCount, onApprove, onDeny, onExpand }) => {
+ const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]);
+ const meta = useMcpToolMeta(parsed);
+
+ const icon = parsed.isMcp
+ ? (meta.integration?.icon || null)
+ : getToolIcon(request.tool_name);
+
+ return (
+
+
+
+ {icon}
+
+
+ {parsed.displayName}
+
+ {remainingCount > 1 && (
+
+ +{remainingCount - 1}
+
+ )}
+
+ { e.stopPropagation(); onApprove(request.id); }}
+ sx={{
+ p: 0,
+ width: 18,
+ height: 18,
+ color: '#fff',
+ bgcolor: c.status.success,
+ '&:hover': { bgcolor: c.status.success, filter: 'brightness(0.85)' },
+ }}
+ >
+
+
+
+
+ { e.stopPropagation(); onDeny(request.id); }}
+ sx={{
+ p: 0,
+ width: 18,
+ height: 18,
+ color: c.status.error,
+ border: `1px solid ${c.status.error}`,
+ '&:hover': { bgcolor: `${c.status.error}0a` },
+ }}
+ >
+
+
+
+
+ { e.stopPropagation(); onExpand(); }}
+ sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }}
+ >
+
+
+
+
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Expanded card
+// ---------------------------------------------------------------------------
+
+const ExpandedCard: React.FC<{
+ c: ReturnType;
+ groups: SessionApprovalGroup[];
+ totalApprovals: number;
+ activeAgents: TrackedAgent[];
+ finishedAgents: TrackedAgent[];
+ hasApprovals: boolean;
+ hasAgents: boolean;
+ onApprove: (requestId: string, updatedInput?: Record) => void;
+ onDeny: (requestId: string, message?: string) => void;
+ onStopAgent: (id: string) => void;
+ onDismissAgent: (id: string) => void;
+ onNavigateToDashboard: (dashboardId: string, agentId: string) => void;
+ onClearAllFinished: () => void;
+ onCollapse: () => void;
+}> = ({
+ c, groups, totalApprovals,
+ activeAgents, finishedAgents, hasApprovals, hasAgents,
+ onApprove, onDeny, onStopAgent, onDismissAgent, onNavigateToDashboard, onClearAllFinished, onCollapse,
+}) => {
+ const [completedExpanded, setCompletedExpanded] = useState(false);
+ const headerTitle = hasApprovals && !hasAgents
+ ? 'Approval Required'
+ : hasAgents && !hasApprovals
+ ? 'Agents'
+ : 'Notifications';
+
+ const badgeCount = totalApprovals + activeAgents.length;
+
+ return (
+
+ {/* Header */}
+
+
+ {headerTitle}
+
+ {badgeCount > 0 && (
+
+ {badgeCount}
+
+ )}
+ {!hasApprovals && (
+ { e.stopPropagation(); onCollapse(); }}
+ sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }}
+ >
+
+
+ )}
+
+
+ {/* Content */}
+
+ {hasApprovals && (
+
+ {hasAgents && (
+
+ Approvals
+
+ )}
+ {groups.map((group) => (
+
+ {groups.length > 1 && (
+
+ {group.sessionName}
+
+ )}
+ {group.approvals.length > 1 ? (
+
+ ) : (
+ group.approvals.map((req) => (
+
+ ))
+ )}
+
+ ))}
+
+ )}
+
+ {hasApprovals && hasAgents && (
+
+ )}
+
+ {hasAgents && (
+
+ {hasApprovals && (
+
+ Agents
+
+ )}
+ {activeAgents.map((agent) => (
+
+ ))}
+ {finishedAgents.length > 0 && (
+ <>
+ {activeAgents.length > 0 && (
+
+ )}
+ setCompletedExpanded((v) => !v)}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 1,
+ px: 2,
+ py: 0.5,
+ cursor: 'pointer',
+ userSelect: 'none',
+ '&:hover': { bgcolor: c.border.subtle },
+ transition: 'background-color 0.15s',
+ }}
+ >
+
+ Completed ({finishedAgents.length})
+
+ { e.stopPropagation(); onClearAllFinished(); }}
+ sx={{
+ fontSize: '0.58rem',
+ fontWeight: 600,
+ color: c.text.ghost,
+ cursor: 'pointer',
+ '&:hover': { color: c.text.secondary },
+ transition: 'color 0.15s',
+ }}
+ >
+ Clear all
+
+
+ {completedExpanded
+ ?
+ : }
+
+
+
+ {finishedAgents.map((agent) => (
+
+ ))}
+
+ >
+ )}
+
+ )}
+
+
+ );
+};
+
+export default DynamicIsland;
diff --git a/frontend/src/app/components/ElementSelectionContext.tsx b/frontend/src/app/components/ElementSelectionContext.tsx
index d42e5d1f..5db25ba1 100644
--- a/frontend/src/app/components/ElementSelectionContext.tsx
+++ b/frontend/src/app/components/ElementSelectionContext.tsx
@@ -1,4 +1,4 @@
-import React, { createContext, useContext, useState, useRef, useCallback, RefObject } from 'react';
+import React, { createContext, useContext, useState, useRef, useCallback, useMemo, RefObject } from 'react';
export interface SelectedElement {
id: string;
@@ -20,11 +20,17 @@ interface ElementSelectionContextValue {
setSelectMode: (active: boolean) => void;
excludeSelectId: string | null;
setExcludeSelectId: (id: string | null) => void;
+ activeOwnerId: string | null;
+ setActiveOwnerId: (id: string | null) => void;
selectedElements: SelectedElement[];
addSelectedElement: (el: SelectedElement) => void;
updateSelectedElement: (id: string, patch: Partial) => void;
removeSelectedElement: (id: string) => void;
clearSelectedElements: () => void;
+ elementsByOwner: Record;
+ addElementForOwner: (ownerId: string, el: SelectedElement) => void;
+ removeOwnerElement: (ownerId: string, elementId: string) => void;
+ clearOwnerElements: (ownerId: string) => void;
iframeRef: RefObject;
}
@@ -37,9 +43,18 @@ export function useElementSelection() {
export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [selectMode, setSelectMode] = useState(false);
const [excludeSelectId, setExcludeSelectId] = useState(null);
- const [selectedElements, setSelectedElements] = useState([]);
+ const [activeOwnerId, setActiveOwnerId] = useState(null);
+ const [elementsByOwner, setElementsByOwner] = useState>({});
const iframeRef = useRef(null);
+ const activeOwnerIdRef = useRef(activeOwnerId);
+ activeOwnerIdRef.current = activeOwnerId;
+
+ const selectedElements = useMemo(
+ () => (activeOwnerId ? elementsByOwner[activeOwnerId] ?? [] : []),
+ [activeOwnerId, elementsByOwner],
+ );
+
const toggleSelectMode = useCallback(() => {
setSelectMode((prev) => {
if (prev) setExcludeSelectId(null);
@@ -48,22 +63,65 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
}, []);
const addSelectedElement = useCallback((el: SelectedElement) => {
- setSelectedElements((prev) => {
- if (prev.some((e) => e.id === el.id)) return prev;
- return [...prev, el];
+ const ownerId = activeOwnerIdRef.current;
+ if (!ownerId) return;
+ setElementsByOwner((prev) => {
+ const existing = prev[ownerId] ?? [];
+ if (existing.some((e) => e.id === el.id)) return prev;
+ return { ...prev, [ownerId]: [...existing, el] };
});
}, []);
const updateSelectedElement = useCallback((id: string, patch: Partial) => {
- setSelectedElements((prev) => prev.map((e) => e.id === id ? { ...e, ...patch } : e));
+ const ownerId = activeOwnerIdRef.current;
+ if (!ownerId) return;
+ setElementsByOwner((prev) => {
+ const existing = prev[ownerId];
+ if (!existing) return prev;
+ return { ...prev, [ownerId]: existing.map((e) => (e.id === id ? { ...e, ...patch } : e)) };
+ });
}, []);
const removeSelectedElement = useCallback((id: string) => {
- setSelectedElements((prev) => prev.filter((e) => e.id !== id));
+ const ownerId = activeOwnerIdRef.current;
+ if (!ownerId) return;
+ setElementsByOwner((prev) => {
+ const existing = prev[ownerId];
+ if (!existing) return prev;
+ return { ...prev, [ownerId]: existing.filter((e) => e.id !== id) };
+ });
}, []);
const clearSelectedElements = useCallback(() => {
- setSelectedElements([]);
+ const ownerId = activeOwnerIdRef.current;
+ if (!ownerId) return;
+ setElementsByOwner((prev) => {
+ if (!prev[ownerId]?.length) return prev;
+ return { ...prev, [ownerId]: [] };
+ });
+ }, []);
+
+ const addElementForOwner = useCallback((ownerId: string, el: SelectedElement) => {
+ setElementsByOwner((prev) => {
+ const existing = prev[ownerId] ?? [];
+ if (existing.some((e) => e.semanticData?.selectId === el.semanticData?.selectId)) return prev;
+ return { ...prev, [ownerId]: [...existing, el] };
+ });
+ }, []);
+
+ const removeOwnerElement = useCallback((ownerId: string, elementId: string) => {
+ setElementsByOwner((prev) => {
+ const existing = prev[ownerId];
+ if (!existing) return prev;
+ return { ...prev, [ownerId]: existing.filter((e) => e.id !== elementId) };
+ });
+ }, []);
+
+ const clearOwnerElements = useCallback((ownerId: string) => {
+ setElementsByOwner((prev) => {
+ if (!prev[ownerId]?.length) return prev;
+ return { ...prev, [ownerId]: [] };
+ });
}, []);
return (
@@ -74,11 +132,17 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
setSelectMode,
excludeSelectId,
setExcludeSelectId,
+ activeOwnerId,
+ setActiveOwnerId,
selectedElements,
addSelectedElement,
updateSelectedElement,
removeSelectedElement,
clearSelectedElements,
+ elementsByOwner,
+ addElementForOwner,
+ removeOwnerElement,
+ clearOwnerElements,
iframeRef,
}}
>
diff --git a/frontend/src/app/components/GlobalApprovalOverlay.tsx b/frontend/src/app/components/GlobalApprovalOverlay.tsx
deleted file mode 100644
index 4405e7bf..00000000
--- a/frontend/src/app/components/GlobalApprovalOverlay.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import React, { useMemo, useCallback, useState, useEffect } from 'react';
-import Box from '@mui/material/Box';
-import Typography from '@mui/material/Typography';
-import Chip from '@mui/material/Chip';
-import IconButton from '@mui/material/IconButton';
-import ExpandLessIcon from '@mui/icons-material/ExpandLess';
-import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
-import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
-import { useAppDispatch, useAppSelector } from '@/shared/hooks';
-import { handleApproval, ApprovalRequest } from '@/shared/state/agentsSlice';
-import ApprovalBar, { BatchApprovalBar } from '@/app/pages/AgentChat/ApprovalBar';
-import { useClaudeTokens } from '@/shared/styles/ThemeContext';
-
-interface SessionApprovalGroup {
- sessionId: string;
- sessionName: string;
- approvals: ApprovalRequest[];
-}
-
-const GlobalApprovalOverlay: React.FC = () => {
- const c = useClaudeTokens();
- const dispatch = useAppDispatch();
- const sessions = useAppSelector((state) => state.agents.sessions);
- const [collapsed, setCollapsed] = useState(false);
-
- const groups: SessionApprovalGroup[] = useMemo(() => {
- const result: SessionApprovalGroup[] = [];
- for (const [sessionId, session] of Object.entries(sessions)) {
- if (session.pending_approvals.length > 0) {
- result.push({
- sessionId,
- sessionName: session.name || 'Agent',
- approvals: session.pending_approvals,
- });
- }
- }
- return result;
- }, [sessions]);
-
- const totalApprovals = useMemo(
- () => groups.reduce((sum, g) => sum + g.approvals.length, 0),
- [groups],
- );
-
- useEffect(() => {
- if (totalApprovals > 0) {
- setCollapsed(false);
- }
- }, [totalApprovals]);
-
- const onApprove = useCallback(
- (requestId: string, updatedInput?: Record) => {
- dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput }));
- },
- [dispatch],
- );
-
- const onDeny = useCallback(
- (requestId: string, message?: string) => {
- dispatch(handleApproval({ requestId, behavior: 'deny', message }));
- },
- [dispatch],
- );
-
- if (totalApprovals === 0) return null;
-
- return (
-
- {/* Header */}
- setCollapsed((v) => !v)}
- sx={{
- display: 'flex',
- alignItems: 'center',
- gap: 1,
- px: 2,
- py: 1.25,
- bgcolor: c.status.warningBg,
- borderBottom: collapsed ? 'none' : `1px solid ${c.status.warning}20`,
- cursor: 'pointer',
- userSelect: 'none',
- '&:hover': { bgcolor: `${c.status.warning}18` },
- transition: 'background-color 0.15s',
- }}
- >
-
-
- Approval Required
-
-
-
- {collapsed ? : }
-
-
-
- {/* Content */}
- {!collapsed && (
-
- {groups.map((group) => (
-
- {groups.length > 1 && (
-
- {group.sessionName}
-
- )}
- {group.approvals.length > 1 ? (
-
- ) : (
- group.approvals.map((req) => (
-
- ))
- )}
-
- ))}
-
- )}
-
- );
-};
-
-export default GlobalApprovalOverlay;
diff --git a/frontend/src/app/components/KeyboardShortcutsHelp.tsx b/frontend/src/app/components/KeyboardShortcutsHelp.tsx
index 1447a635..23e9aa29 100644
--- a/frontend/src/app/components/KeyboardShortcutsHelp.tsx
+++ b/frontend/src/app/components/KeyboardShortcutsHelp.tsx
@@ -11,6 +11,7 @@ const shortcuts = [
{ key: 't', description: 'Go to Templates' },
{ key: '1-9', description: 'Open agent by position' },
{ key: '⌘M', description: 'Add App' },
+ { key: '⌘N', description: 'New Browser' },
{ key: '⌘O', description: 'History' },
{ key: 'Shift+A', description: 'Approve all pending' },
{ key: 'Shift+D', description: 'Deny all pending' },
diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx
index 2d4edc02..59776b65 100644
--- a/frontend/src/app/components/Layout/AppShell.tsx
+++ b/frontend/src/app/components/Layout/AppShell.tsx
@@ -27,17 +27,24 @@ import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined';
import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
+import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
+import CloseIcon from '@mui/icons-material/Close';
+import LinearProgress from '@mui/material/LinearProgress';
import Settings from '@/app/pages/Settings/Settings';
-import GlobalApprovalOverlay from '@/app/components/GlobalApprovalOverlay';
+import DynamicIsland from '@/app/components/DynamicIsland';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
+import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice';
+import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
+import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 400;
const SIDEBAR_DEFAULT = 220;
const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width';
+const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed';
const CUSTOMIZATION_ITEMS = [
{ label: 'Prompts', path: '/templates', icon: },
@@ -75,10 +82,34 @@ const AppShell: React.FC = () => {
const updateStatus = useAppSelector((state) => state.update.status);
const availableVersion = useAppSelector((state) => state.update.availableVersion);
- const [updateBannerDismissed, setUpdateBannerDismissed] = useState(false);
+ const downloadPercent = useAppSelector((state) => state.update.downloadPercent);
- const showUpdateDot = updateStatus === 'available' || updateStatus === 'downloaded';
- const showUpdateBanner = updateStatus === 'downloaded' && !updateBannerDismissed;
+ const [dismissedVersion, setDismissedVersion] = useState(() => {
+ try { return localStorage.getItem(UPDATE_DISMISS_KEY); } catch { return null; }
+ });
+ const [snackbarDismissed, setSnackbarDismissed] = useState(false);
+
+ const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion;
+ const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading';
+
+ const showUpdateDot = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion;
+ const showUpdateBanner = isUpdateActionable && !bannerDismissedForVersion;
+ const showUpdateSnackbar = (updateStatus === 'available' || updateStatus === 'downloaded') && !bannerDismissedForVersion && !snackbarDismissed;
+
+ const handleDismissBanner = useCallback(() => {
+ if (availableVersion) {
+ try { localStorage.setItem(UPDATE_DISMISS_KEY, availableVersion); } catch {}
+ setDismissedVersion(availableVersion);
+ }
+ }, [availableVersion]);
+
+ const handleDownloadUpdate = useCallback(async () => {
+ try { await (window as any).openswarm?.downloadUpdate(); } catch {}
+ }, []);
+
+ const handleInstallUpdate = useCallback(() => {
+ (window as any).openswarm?.installUpdate();
+ }, []);
const dashboardItems = useAppSelector((state) => state.dashboards.items);
const dashboardList = Object.values(dashboardItems).sort(
@@ -95,6 +126,75 @@ const AppShell: React.FC = () => {
dispatch(fetchOutputs());
}, [dispatch]);
+ const openUrlInBrowser = useCallback((url: string, webContentsId?: number) => {
+ const dashMatch = location.pathname.match(/^\/dashboard\/(.+)/);
+ if (dashMatch) {
+ if (webContentsId != null) {
+ const browserId = findBrowserByWebContentsId(webContentsId);
+ if (browserId) {
+ dispatch(addBrowserTab({ browserId, url, makeActive: true }));
+ return;
+ }
+ }
+ dispatch(addBrowserCard({ url }));
+ } else {
+ dispatch(setPendingBrowserUrl(url));
+ const lastId = (window as any).__openswarm_last_dashboard_id as string | undefined;
+ const firstDashboard = dashboardList[0];
+ const targetId = lastId || firstDashboard?.id;
+ if (targetId) {
+ navigate(`/dashboard/${targetId}`);
+ } else {
+ dispatch(createDashboard('Untitled Dashboard')).then((result: any) => {
+ if (createDashboard.fulfilled.match(result)) {
+ navigate(`/dashboard/${result.payload.id}`);
+ }
+ });
+ }
+ }
+ }, [location.pathname, dashboardList, dispatch, navigate]);
+
+ useEffect(() => {
+ let lastUrl = '';
+ let lastTime = 0;
+
+ const handleClick = (e: MouseEvent) => {
+ const anchor = (e.target as HTMLElement)?.closest?.('a');
+ if (!anchor) return;
+ const href = anchor.getAttribute('href');
+ if (!href) return;
+ if (!/^https?:\/\//i.test(href)) return;
+ if (href.startsWith('http://localhost:')) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ const now = Date.now();
+ if (href === lastUrl && now - lastTime < 1000) return;
+ lastUrl = href;
+ lastTime = now;
+
+ openUrlInBrowser(href);
+ };
+
+ document.addEventListener('click', handleClick, true);
+ return () => document.removeEventListener('click', handleClick, true);
+ }, [openUrlInBrowser]);
+
+ useEffect(() => {
+ const w = window as any;
+ if (!w.openswarm?.onWebviewNewWindow) return;
+ let lastUrl = '';
+ let lastTime = 0;
+ return w.openswarm.onWebviewNewWindow((url: string, webContentsId: number) => {
+ const now = Date.now();
+ if (url === lastUrl && now - lastTime < 1000) return;
+ lastUrl = url;
+ lastTime = now;
+ openUrlInBrowser(url, webContentsId);
+ });
+ }, [openUrlInBrowser]);
+
useEffect(() => {
try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {}
}, [sidebarWidth]);
@@ -196,6 +296,8 @@ const AppShell: React.FC = () => {
borderBottom: `0.5px solid ${c.border.medium}`,
display: 'flex',
alignItems: 'center',
+ position: 'relative',
+ overflow: 'visible',
WebkitAppRegion: 'drag',
userSelect: 'none',
pl: '78px',
@@ -248,6 +350,8 @@ const AppShell: React.FC = () => {
+
+
{
component="img"
src="./logo.png"
alt="OpenSwarm"
- sx={{ width: 18, height: 18, borderRadius: 0.5, opacity: 0.7 }}
+ sx={{ width: 16, height: 16, borderRadius: 0.5, opacity: 0.6 }}
/>
{
+ {showUpdateBanner && (
+
+
+
+ {updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
+ {updateStatus === 'downloading' && `Downloading OpenSwarm ${availableVersion}…`}
+ {updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} is ready to install`}
+
+ {updateStatus === 'downloading' && (
+
+ )}
+ {updateStatus === 'downloading' && (
+
+ {Math.round(downloadPercent)}%
+
+ )}
+ {updateStatus === 'available' && (
+
+ )}
+ {updateStatus === 'downloaded' && (
+
+ )}
+
+
+
+
+ )}
+
{!sidebarCollapsed && (
<>
@@ -732,39 +928,62 @@ const AppShell: React.FC = () => {
-
setSnackbarDismissed(true)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
}
+ icon={updateStatus === 'downloaded'
+ ?
+ :
+ }
action={
-
+ {updateStatus === 'available' && (
+
+ )}
+ {updateStatus === 'downloaded' && (
+
+ )}
}
sx={{
@@ -775,7 +994,8 @@ const AppShell: React.FC = () => {
'& .MuiAlert-icon': { color: c.accent.primary },
}}
>
- OpenSwarm {availableVersion} downloaded — restart to update
+ {updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
+ {updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} downloaded — restart to update`}
diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx
new file mode 100644
index 00000000..d046d3ac
--- /dev/null
+++ b/frontend/src/app/components/OnboardingModal.tsx
@@ -0,0 +1,222 @@
+import React, { useState, useEffect } from 'react';
+import { Box, Typography, Modal, Button } from '@mui/material';
+import { useAppSelector } from '@/shared/hooks';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+import { API_BASE } from '@/shared/config';
+
+const SUBSCRIPTION_PROVIDERS = [
+ { id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false },
+ { id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4', preview: true },
+ { id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, o3, o4-mini', color: '#74AA9C', preview: true },
+ { id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models', color: '#8B949E', preview: true },
+];
+
+const OnboardingModal: React.FC = () => {
+ const c = useClaudeTokens();
+ const settings = useAppSelector((s) => s.settings);
+ const [open, setOpen] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+ const [connecting, setConnecting] = useState(null);
+ const [nineRouterStatus, setNineRouterStatus] = useState(null);
+
+ // Check if user has any credentials configured
+ const hasAnyKey = !!(
+ settings.anthropic_api_key ||
+ settings.openai_api_key ||
+ settings.google_api_key ||
+ settings.openrouter_api_key
+ );
+
+ // Check 9Router subscription status
+ useEffect(() => {
+ fetch(`${API_BASE}/agents/subscriptions/status`)
+ .then((r) => r.json())
+ .then(setNineRouterStatus)
+ .catch(() => setNineRouterStatus(null));
+ }, []);
+
+ const hasSubscription = (() => {
+ if (!nineRouterStatus?.running) return false;
+ const connections = nineRouterStatus?.providers?.connections || [];
+ return connections.some((p: any) => p.isActive);
+ })();
+
+ // Show modal if no keys AND no subscriptions AND not dismissed
+ useEffect(() => {
+ if (!hasAnyKey && !hasSubscription && !dismissed && nineRouterStatus !== null) {
+ setOpen(true);
+ } else {
+ setOpen(false);
+ }
+ }, [hasAnyKey, hasSubscription, dismissed, nineRouterStatus]);
+
+ const handleConnect = async (providerId: string) => {
+ setConnecting(providerId);
+ try {
+ const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ provider: providerId }),
+ });
+ const data = await r.json();
+
+ if (data.flow === 'device_code') {
+ const verifyUrl = data.verification_uri;
+ if (verifyUrl) window.open(verifyUrl, '_blank');
+ // Poll for completion
+ const timer = setInterval(async () => {
+ try {
+ const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ provider: providerId, device_code: data.device_code, code_verifier: data.code_verifier, extra_data: data.extra_data }),
+ });
+ const pd = await pr.json();
+ if (pd.success) {
+ clearInterval(timer);
+ setConnecting(null);
+ setOpen(false);
+ }
+ } catch {}
+ }, 5000);
+ setTimeout(() => { clearInterval(timer); setConnecting(null); }, 300000);
+ } else if (data.flow === 'authorization_code') {
+ // Open auth URL as popup — window.opener lets callback page postMessage back
+ const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
+
+ // Listen for postMessage from 9Router's callback page
+ // 9Router sends: { type: "oauth_callback", data: { code, state, ... } }
+ const msgHandler = async (event: MessageEvent) => {
+ const d = event.data;
+ const callbackData = d?.type === 'oauth_callback' ? d.data : d;
+ if (callbackData?.code) {
+ window.removeEventListener('message', msgHandler);
+ clearInterval(statusPoller);
+ if (popup && !popup.closed) popup.close();
+ try {
+ await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ provider: providerId, code: callbackData.code,
+ redirect_uri: data.redirect_uri, code_verifier: data.code_verifier,
+ state: callbackData.state || data.state,
+ }),
+ });
+ } catch {}
+ setConnecting(null);
+ setOpen(false);
+ }
+ };
+ window.addEventListener('message', msgHandler);
+
+ // Also poll status as fallback (in case postMessage doesn't work in Electron)
+ const statusPoller = setInterval(async () => {
+ try {
+ const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
+ const sd = await sr.json();
+ const conns = sd.providers?.connections || [];
+ if (conns.some((p: any) => p.provider === providerId && p.isActive)) {
+ clearInterval(statusPoller);
+ window.removeEventListener('message', msgHandler);
+ setConnecting(null);
+ setOpen(false);
+ }
+ } catch {}
+ }, 2000);
+ setTimeout(() => { clearInterval(statusPoller); window.removeEventListener('message', msgHandler); setConnecting(null); }, 300000);
+ }
+ } catch {
+ setConnecting(null);
+ }
+ };
+
+ const handleApiKey = () => {
+ setDismissed(true);
+ setOpen(false);
+ // User will manually go to Settings → Models to add API keys
+ };
+
+ const handleSkip = () => {
+ setDismissed(true);
+ setOpen(false);
+ };
+
+ if (!open) return null;
+
+ return (
+
+
+
+ Welcome to OpenSwarm
+
+
+ Connect an AI model to get started
+
+
+ {/* Subscription options */}
+
+ Use your existing subscription
+
+
+ {SUBSCRIPTION_PROVIDERS.map((p) => (
+ !p.preview && !connecting && handleConnect(p.id)}
+ sx={{
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
+ cursor: p.preview ? 'default' : connecting ? 'wait' : 'pointer',
+ opacity: p.preview ? 0.5 : 1,
+ transition: 'border-color 0.15s, background 0.15s',
+ ...(!p.preview && { '&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` } }),
+ }}
+ >
+
+ {p.name}
+ {p.desc}
+
+
+ {p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : 'Connect →'}
+
+
+ ))}
+
+
+ {/* API key option */}
+
+ Or use an API key
+
+
+
+ I have an API key
+
+
+ Go to Settings → Models to enter your key
+
+
+
+ {/* Skip */}
+
+
+
+ );
+};
+
+export default OnboardingModal;
diff --git a/frontend/src/app/components/TalkModeOverlay.tsx b/frontend/src/app/components/TalkModeOverlay.tsx
new file mode 100644
index 00000000..808e6d69
--- /dev/null
+++ b/frontend/src/app/components/TalkModeOverlay.tsx
@@ -0,0 +1,526 @@
+import React, { useEffect, useRef, useState, useCallback } from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import IconButton from '@mui/material/IconButton';
+import MicIcon from '@mui/icons-material/Mic';
+import MicOffIcon from '@mui/icons-material/MicOff';
+import VolumeUpIcon from '@mui/icons-material/VolumeUp';
+import CloseIcon from '@mui/icons-material/Close';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+import { WS_BASE } from '@/shared/config';
+
+type FaceState = 'idle' | 'happy' | 'thinking' | 'talking' | 'surprised' | 'sleeping' | 'angry' | 'love';
+type TalkStatus = 'idle' | 'listening' | 'processing' | 'speaking';
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ sessionId?: string;
+}
+
+// ─── Pixel Face Canvas Renderer ──────────────────────────────────
+// Ported from face.html — all the draw logic in one hook.
+
+function usePixelFace(
+ canvasRef: React.RefObject,
+ faceState: FaceState,
+ size: number,
+) {
+ const stateRef = useRef('idle');
+ const animRef = useRef(0);
+
+ useEffect(() => {
+ stateRef.current = faceState;
+ }, [faceState]);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const PX = Math.max(4, Math.floor(size / 30));
+ const COLS = Math.ceil(size / PX);
+ const ROWS = Math.ceil(size / PX);
+ canvas.width = COLS * PX;
+ canvas.height = ROWS * PX;
+
+ const BG = '#E8927A';
+ const EYE = '#1E1E1E';
+ const MOUTH = '#1E1E1E';
+
+ let breath = 0, talk = 0, think = 0, sleepZ = 0, heartP = 0;
+ let eyeH = 3, eyeHTarget = 3;
+ let mouthW = 2, mouthWTarget = 2;
+ let mouthH = 2, mouthHTarget = 2;
+ let eyeOffX = 0, eyeOffXTarget = 0;
+ let eyeOffY = 0, eyeOffYTarget = 0;
+ let blinkOpen = true, blinkCD = 120 + Math.random() * 200;
+ let doubleBlink = false;
+ let idleSinceInput = 0, sleepTransitioned = false;
+ let idleAction = 'none', idleActionTimer = 0;
+ let idleGlanceX = 0, idleGlanceY = 0;
+
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
+
+ const px = (col: number, row: number, color: string) => {
+ ctx.fillStyle = color;
+ ctx.fillRect(col * PX, row * PX, PX, PX);
+ };
+
+ const pxRect = (x: number, y: number, w: number, h: number, color: string) => {
+ for (let r = 0; r < Math.round(h); r++)
+ for (let c = 0; c < Math.round(w); c++)
+ px(Math.round(x) + c, Math.round(y) + r, color);
+ };
+
+ function draw() {
+ const state = stateRef.current;
+
+ breath += 0.025;
+ talk += 0.3;
+ think += 0.025;
+ sleepZ += 0.012;
+ heartP += 0.05;
+ idleSinceInput++;
+
+ if (idleSinceInput > 2700 && state === 'idle' && !sleepTransitioned) {
+ stateRef.current = 'sleeping';
+ sleepTransitioned = true;
+ }
+
+ if (state === 'idle') {
+ idleActionTimer--;
+ blinkCD--;
+ if (blinkCD <= 0 && blinkOpen) { blinkOpen = false; blinkCD = 6; doubleBlink = Math.random() < 0.3; }
+ else if (!blinkOpen && blinkCD <= 0) { blinkOpen = true; blinkCD = doubleBlink ? 8 : 100 + Math.random() * 280; doubleBlink = false; }
+
+ if (idleActionTimer <= 0) {
+ const roll = Math.random();
+ if (roll < 0.3) { idleAction = 'glance'; idleGlanceX = Math.floor(Math.random() * 5) - 2; idleGlanceY = (Math.random() - 0.5) * 1.2; idleActionTimer = 50 + Math.random() * 120; }
+ else if (roll < 0.45) { idleAction = 'scan'; idleActionTimer = 180; }
+ else if (roll < 0.55) { idleAction = 'squint'; idleActionTimer = 35 + Math.random() * 40; }
+ else if (roll < 0.65) { idleAction = 'lookup'; idleActionTimer = 50 + Math.random() * 70; }
+ else { idleAction = 'none'; idleGlanceX = 0; idleGlanceY = 0; idleActionTimer = 60 + Math.random() * 200; }
+ }
+ }
+
+ if (state === 'talking' || state === 'thinking') {
+ blinkCD--;
+ if (blinkCD <= 0 && blinkOpen) { blinkOpen = false; blinkCD = 6; }
+ else if (!blinkOpen && blinkCD <= 0) { blinkOpen = true; blinkCD = 120 + Math.random() * 250; }
+ }
+ if (state !== 'idle' && state !== 'talking' && state !== 'thinking') blinkOpen = true;
+
+ const b = Math.sin(breath) * 0.3;
+ switch (state) {
+ case 'idle': {
+ eyeHTarget = blinkOpen ? 3 : 0; mouthWTarget = 2; mouthHTarget = 2;
+ let gx = 0, gy = b;
+ if (idleAction === 'glance') { gx = idleGlanceX; gy = idleGlanceY + b; }
+ else if (idleAction === 'scan') { const t = 1 - (idleActionTimer / 180); gx = Math.sin(t * Math.PI * 2) * 2.5; }
+ else if (idleAction === 'squint') { eyeHTarget = blinkOpen ? 2 : 0; gy = b + 0.3; }
+ else if (idleAction === 'lookup') { gy = -1.2 + b; }
+ eyeOffXTarget = gx; eyeOffYTarget = gy; break;
+ }
+ case 'happy': eyeHTarget = 1; mouthWTarget = 6; mouthHTarget = 1; eyeOffXTarget = 0; eyeOffYTarget = b; break;
+ case 'thinking': eyeHTarget = blinkOpen ? 3 : 0; mouthWTarget = 2; mouthHTarget = 2; eyeOffXTarget = 2; eyeOffYTarget = b; break;
+ case 'talking': { eyeHTarget = blinkOpen ? 3 : 0; const open = Math.round(Math.abs(Math.sin(talk)) * 2 + 1); mouthWTarget = 4; mouthHTarget = open; eyeOffXTarget = 0; eyeOffYTarget = b; break; }
+ case 'surprised': eyeHTarget = 4; mouthWTarget = 3; mouthHTarget = 3; eyeOffXTarget = 0; eyeOffYTarget = b; break;
+ case 'sleeping': eyeHTarget = 1; mouthWTarget = 2; mouthHTarget = 1; eyeOffXTarget = 0; eyeOffYTarget = b * 2; break;
+ case 'angry': eyeHTarget = 2; mouthWTarget = 6; mouthHTarget = 1; eyeOffXTarget = 0; eyeOffYTarget = b * 0.3; break;
+ case 'love': eyeHTarget = 3; mouthWTarget = 2; mouthHTarget = 2; eyeOffXTarget = 0; eyeOffYTarget = b; break;
+ }
+
+ eyeH = lerp(eyeH, eyeHTarget, 0.18);
+ mouthW = lerp(mouthW, mouthWTarget, 0.15);
+ mouthH = lerp(mouthH, mouthHTarget, 0.2);
+ eyeOffX = lerp(eyeOffX, eyeOffXTarget, 0.1);
+ eyeOffY = lerp(eyeOffY, eyeOffYTarget, 0.15);
+
+ // Draw
+ ctx.fillStyle = BG;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ const cx = Math.floor(COLS / 2);
+ const cy = Math.floor(ROWS / 2);
+ const eyeSpread = 5, eyeW = 3;
+ const eh = Math.max(1, Math.round(eyeH));
+ const eOffX = Math.round(eyeOffX), eOffY = Math.round(eyeOffY);
+ const eyeBaseY = cy - 2;
+ const blinkOff = Math.round((3 - eh) / 2);
+
+ if (state === 'love') {
+ const pulse = Math.sin(heartP) > 0 ? '#CC2244' : '#BB1E3E';
+ const heart = (hx: number, hy: number) => {
+ px(hx - 1, hy, pulse); px(hx + 1, hy, pulse);
+ px(hx - 2, hy + 1, pulse); px(hx - 1, hy + 1, pulse); px(hx, hy + 1, pulse); px(hx + 1, hy + 1, pulse); px(hx + 2, hy + 1, pulse);
+ px(hx - 1, hy + 2, pulse); px(hx, hy + 2, pulse); px(hx + 1, hy + 2, pulse);
+ px(hx, hy + 3, pulse);
+ };
+ heart(cx - eyeSpread + eOffX, eyeBaseY + eOffY);
+ heart(cx + eyeSpread + eOffX, eyeBaseY + eOffY);
+ } else {
+ pxRect(cx - eyeSpread - 1 + eOffX, eyeBaseY + blinkOff + eOffY, eyeW, eh, EYE);
+ pxRect(cx + eyeSpread - 1 + eOffX, eyeBaseY + blinkOff + eOffY, eyeW, eh, EYE);
+ }
+
+ if (state === 'angry') {
+ const lx = cx - eyeSpread - 1 + eOffX, ly = eyeBaseY + blinkOff + eOffY - 2;
+ px(lx, ly + 1, EYE); px(lx + 1, ly, EYE); px(lx + 2, ly, EYE);
+ const rx = cx + eyeSpread - 1 + eOffX;
+ px(rx + 2, ly + 1, EYE); px(rx + 1, ly, EYE); px(rx, ly, EYE);
+ }
+
+ const mw = Math.max(1, Math.round(mouthW)), mh = Math.max(1, Math.round(mouthH));
+ const mouthY = cy + 4 + Math.round(eyeOffY);
+
+ if (state === 'happy') {
+ pxRect(cx - Math.floor(mw / 2), mouthY, mw, 1, MOUTH);
+ px(cx - Math.floor(mw / 2), mouthY - 1, MOUTH);
+ px(cx - Math.floor(mw / 2) + mw - 1, mouthY - 1, MOUTH);
+ } else if (state === 'angry') {
+ pxRect(cx - Math.floor(mw / 2), mouthY, mw, 1, MOUTH);
+ px(cx - Math.floor(mw / 2), mouthY + 1, MOUTH);
+ px(cx - Math.floor(mw / 2) + mw - 1, mouthY + 1, MOUTH);
+ } else {
+ pxRect(cx - Math.floor(mw / 2), mouthY, mw, mh, MOUTH);
+ }
+
+ if (state === 'sleeping') {
+ const zFrame = Math.floor(sleepZ * 60) % 90;
+ const zy = Math.round(cy - 5 - (zFrame / 90) * 4);
+ const zx = cx + eyeSpread + 3;
+ if (zy >= 1 && zFrame < 70) {
+ px(zx, zy, '#5577CC'); px(zx + 1, zy, '#5577CC');
+ px(zx + 1, zy + 1, '#5577CC');
+ px(zx, zy + 2, '#5577CC'); px(zx + 1, zy + 2, '#5577CC');
+ }
+ }
+
+ if (state === 'thinking') {
+ const phase = Math.floor(think * 10) % 4;
+ const dx = cx + eyeSpread + 3, dy = cy - 5;
+ if (phase >= 1) px(dx, dy, '#7799DD');
+ if (phase >= 2) px(dx + 1, dy - 1, '#7799DD');
+ if (phase >= 3) px(dx + 2, dy - 2, '#7799DD');
+ }
+
+ animRef.current = requestAnimationFrame(draw);
+ }
+
+ animRef.current = requestAnimationFrame(draw);
+ return () => cancelAnimationFrame(animRef.current);
+ }, [canvasRef, size]);
+}
+
+// ─── Status Labels ───────────────────────────────────────────────
+
+const STATUS_LABELS: Record = {
+ idle: 'Tap to speak',
+ listening: 'Listening...',
+ processing: 'Thinking...',
+ speaking: 'Speaking...',
+};
+
+// ─── Main Component ─────────────────────────────────────────────
+
+const TalkModeOverlay: React.FC = ({ open, onClose, sessionId }) => {
+ const c = useClaudeTokens();
+ const canvasRef = useRef(null);
+ const [faceState, setFaceState] = useState('idle');
+ const [talkStatus, setTalkStatus] = useState('idle');
+ const [transcript, setTranscript] = useState('');
+ const [agentResponse, setAgentResponse] = useState('');
+ const wsRef = useRef(null);
+ const mediaRecorderRef = useRef(null);
+ const silenceTimerRef = useRef(0);
+ const audioContextRef = useRef(null);
+
+ usePixelFace(canvasRef, faceState, 240);
+
+ // Map talk status to face state
+ useEffect(() => {
+ switch (talkStatus) {
+ case 'idle': setFaceState('idle'); break;
+ case 'listening': setFaceState('idle'); break;
+ case 'processing': setFaceState('thinking'); break;
+ case 'speaking': setFaceState('talking'); break;
+ }
+ }, [talkStatus]);
+
+ // WebSocket connection for talk mode
+ useEffect(() => {
+ if (!open || !sessionId) return;
+
+ const ws = new WebSocket(`${WS_BASE}/ws/talk/${sessionId}`);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ ws.send(JSON.stringify({ type: 'config', stt: {}, tts: {} }));
+ };
+
+ ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data);
+
+ switch (msg.type) {
+ case 'status':
+ if (msg.status === 'listening') setTalkStatus('idle');
+ else if (msg.status === 'processing') setTalkStatus('processing');
+ else if (msg.status === 'speaking') setTalkStatus('speaking');
+ break;
+
+ case 'transcript':
+ setTranscript(msg.text);
+ break;
+
+ case 'agent_response':
+ setAgentResponse(msg.text);
+ setFaceState('happy');
+ setTimeout(() => setFaceState('idle'), 2000);
+ break;
+
+ case 'audio': {
+ const audioData = atob(msg.data);
+ const audioArray = new Uint8Array(audioData.length);
+ for (let i = 0; i < audioData.length; i++) audioArray[i] = audioData.charCodeAt(i);
+
+ if (!audioContextRef.current) audioContextRef.current = new AudioContext();
+ const audioCtx = audioContextRef.current;
+ audioCtx.decodeAudioData(audioArray.buffer.slice(0), (buffer) => {
+ const source = audioCtx.createBufferSource();
+ source.buffer = buffer;
+ source.connect(audioCtx.destination);
+ source.onended = () => setTalkStatus('idle');
+ source.start(0);
+ setTalkStatus('speaking');
+ });
+ break;
+ }
+ }
+ };
+
+ ws.onerror = () => setFaceState('angry');
+ ws.onclose = () => {};
+
+ return () => {
+ ws.close();
+ wsRef.current = null;
+ };
+ }, [open, sessionId]);
+
+ // Keyboard: Esc to close
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [open, onClose]);
+
+ const startRecording = useCallback(async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
+ mediaRecorderRef.current = recorder;
+
+ const chunks: Blob[] = [];
+ recorder.ondataavailable = (e) => {
+ if (e.data.size > 0) chunks.push(e.data);
+ };
+
+ recorder.onstop = async () => {
+ stream.getTracks().forEach((t) => t.stop());
+ const blob = new Blob(chunks, { type: 'audio/webm' });
+ const reader = new FileReader();
+ reader.onload = () => {
+ const base64 = (reader.result as string).split(',')[1];
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: 'audio', data: base64, format: 'webm' }));
+ wsRef.current.send(JSON.stringify({ type: 'end_utterance', format: 'webm' }));
+ }
+ };
+ reader.readAsDataURL(blob);
+ setTalkStatus('processing');
+ };
+
+ recorder.start();
+ setTalkStatus('listening');
+ setTranscript('');
+ setAgentResponse('');
+
+ // Auto-stop after silence (simple timeout approach)
+ silenceTimerRef.current = window.setTimeout(() => {
+ if (mediaRecorderRef.current?.state === 'recording') {
+ mediaRecorderRef.current.stop();
+ }
+ }, 5000);
+ } catch {
+ setFaceState('angry');
+ }
+ }, []);
+
+ const stopRecording = useCallback(() => {
+ window.clearTimeout(silenceTimerRef.current);
+ if (mediaRecorderRef.current?.state === 'recording') {
+ mediaRecorderRef.current.stop();
+ }
+ }, []);
+
+ const handleMicClick = useCallback(() => {
+ if (talkStatus === 'listening') {
+ stopRecording();
+ } else if (talkStatus === 'idle') {
+ startRecording();
+ }
+ }, [talkStatus, startRecording, stopRecording]);
+
+ if (!open) return null;
+
+ const micBg =
+ talkStatus === 'listening' ? c.accent.primary :
+ talkStatus === 'speaking' ? '#4caf50' :
+ c.bg.elevated;
+
+ return (
+ { if (e.target === e.currentTarget) onClose(); }}
+ >
+ {/* Close button */}
+
+
+
+
+ {/* Face canvas */}
+
+
+
+
+ {/* Status label */}
+
+ {STATUS_LABELS[talkStatus]}
+
+
+ {/* Transcript area */}
+
+ {transcript && (
+
+ "{transcript}"
+
+ )}
+
+ {agentResponse && (
+
+ {agentResponse}
+
+ )}
+
+
+ {/* Mic button */}
+
+ {talkStatus === 'listening' ? :
+ talkStatus === 'speaking' ? :
+ }
+
+
+ {/* Hint */}
+
+ esc to close
+
+
+ );
+};
+
+export default TalkModeOverlay;
diff --git a/frontend/src/app/components/useDomElementSelector.ts b/frontend/src/app/components/useDomElementSelector.ts
index b4535e19..8115769b 100644
--- a/frontend/src/app/components/useDomElementSelector.ts
+++ b/frontend/src/app/components/useDomElementSelector.ts
@@ -120,6 +120,7 @@ export function useDomElementSelector(): DomSelectorState {
const dragOriginRef = useRef<{ x: number; y: number } | null>(null);
const isDraggingRef = useRef(false);
const dragBoundsRef = useRef<{ left: number; top: number; right: number; bottom: number } | null>(null);
+ const preDragFocusRef = useRef(null);
const excludeIdRef = useRef(null);
useEffect(() => {
@@ -245,8 +246,11 @@ export function useDomElementSelector(): DomSelectorState {
if (e.button !== 0) return;
if (e.metaKey || e.ctrlKey) return;
const target = e.target as Element;
- // Only start drag on "empty" canvas areas (not on selectable elements)
- if (target && findSelectableAncestor(target, excludeIdRef.current)) return;
+ if (target && findSelectableAncestor(target, excludeIdRef.current)) {
+ e.preventDefault();
+ return;
+ }
+ preDragFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dragOriginRef.current = { x: e.clientX, y: e.clientY };
isDraggingRef.current = false;
}, []);
@@ -291,12 +295,17 @@ export function useDomElementSelector(): DomSelectorState {
});
}
+ const wasDragging = isDraggingRef.current;
dragOriginRef.current = null;
isDraggingRef.current = false;
dragBoundsRef.current = null;
setDragRect(EMPTY_DRAG);
setDragPreview([]);
if (dragPreviewRafRef.current) cancelAnimationFrame(dragPreviewRafRef.current);
+ if (wasDragging && preDragFocusRef.current) {
+ preDragFocusRef.current.focus();
+ }
+ preDragFocusRef.current = null;
}, [ctx]);
const handleClick = useCallback((e: MouseEvent) => {
@@ -327,6 +336,7 @@ export function useDomElementSelector(): DomSelectorState {
dragOriginRef.current = null;
dragBoundsRef.current = null;
isDraggingRef.current = false;
+ preDragFocusRef.current = null;
return;
}
@@ -353,6 +363,7 @@ export function useDomElementSelector(): DomSelectorState {
dragOriginRef.current = null;
dragBoundsRef.current = null;
isDraggingRef.current = false;
+ preDragFocusRef.current = null;
};
}, [ctx?.selectMode, handleMouseMove, handleMouseDown, handleMouseUp, handleClick]);
diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx
index 0c5c43c5..28d823db 100644
--- a/frontend/src/app/pages/AgentChat/AgentChat.tsx
+++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx
@@ -5,8 +5,16 @@ import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
+import TextField from '@mui/material/TextField';
+import ClickAwayListener from '@mui/material/ClickAwayListener';
import CloseIcon from '@mui/icons-material/Close';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
+import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
+import PlayArrowIcon from '@mui/icons-material/PlayArrow';
+import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
+import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
+import CheckIcon from '@mui/icons-material/Check';
+import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
sendMessage as sendMessageThunk,
@@ -17,6 +25,9 @@ import {
handleApproval,
editMessage,
switchBranch,
+ duplicateSession,
+ setActiveSession,
+ updateSessionProvider,
updateSessionModel,
updateSessionMode,
fetchSession,
@@ -25,19 +36,19 @@ import {
import { fetchModes } from '@/shared/state/modesSlice';
import { createSessionWs } from '@/shared/ws/WebSocketManager';
import MessageBubble from './MessageBubble';
+import MessageActionBar from './MessageActionBar';
import ToolCallBubble, { ToolPair } from './ToolCallBubble';
import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './ToolGroupBubble';
import ApprovalBar, { BatchApprovalBar } from './ApprovalBar';
import ChatInput, { ChatInputHandle } from './ChatInput';
import { ContextPath } from '@/app/components/DirectoryBrowser';
-import BranchNavigator from './BranchNavigator';
import DiffViewer from './DiffViewer';
-import { setGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
+import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
-const CONTEXT_WINDOWS: Record = {
- sonnet: 200_000,
- opus: 200_000,
+const CONTEXT_WINDOWS_DEFAULT: Record = {
+ sonnet: 1_000_000,
+ opus: 1_000_000,
haiku: 200_000,
};
@@ -91,14 +102,27 @@ const ThinkingBubble: React.FC = () => {
);
};
+interface QueuedMessage {
+ prompt: string;
+ images?: Array<{ data: string; media_type: string }>;
+ contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
+ forcedTools?: string[];
+ attachedSkills?: Array<{ id: string; name: string; content: string }>;
+ selectedBrowserIds?: string[];
+}
+
interface AgentChatProps {
sessionId?: string;
onClose?: () => void;
embedded?: boolean;
+ autoFocus?: boolean;
+ isGlowing?: boolean;
+ onDismissGlow?: () => void;
initialContextPaths?: ContextPath[];
+ onBranch?: (newSessionId: string) => void;
}
-const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose, embedded, initialContextPaths }) => {
+const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, isGlowing, onDismissGlow, initialContextPaths, onBranch }) => {
const c = useClaudeTokens();
const STATUS_STYLES: Record = {
running: { color: c.status.success, bg: c.status.successBg },
@@ -112,15 +136,26 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
const dispatch = useAppDispatch();
const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined));
const modesMap = useAppSelector((state) => state.modes.items);
+ const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const scrollContainerRef = useRef(null);
const chatInputRef = useRef(null);
const isAtBottomRef = useRef(true);
const [showScrollButton, setShowScrollButton] = useState(false);
+ const [showResumeBubble, setShowResumeBubble] = useState(false);
+ const [awaitingResponse, setAwaitingResponse] = useState(false);
const [mode, setMode] = useState('agent');
const [model, setModel] = useState('sonnet');
+ const [provider, setProvider] = useState('anthropic');
const wsRef = useRef | null>(null);
const initialContextApplied = useRef(false);
+ const messageQueueRef = useRef([]);
+ const [queueLength, setQueueLength] = useState(0);
+ const [queueExpanded, setQueueExpanded] = useState(false);
+ const [editingQueueIdx, setEditingQueueIdx] = useState(null);
+ const [editingQueueText, setEditingQueueText] = useState('');
+ const [dragIdx, setDragIdx] = useState(null);
+ const [dropTargetIdx, setDropTargetIdx] = useState(null);
const isDraft = session?.status === 'draft';
@@ -153,17 +188,75 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
if (session) setModel(session.model);
}, [session?.model]);
+ useEffect(() => {
+ if (session?.provider) setProvider(session.provider);
+ }, [session?.provider]);
+
useEffect(() => {
if (Object.keys(modesMap).length === 0) dispatch(fetchModes());
}, [dispatch, modesMap]);
+ const dispatchMessage = useCallback((msg: QueuedMessage) => {
+ if (!id) return;
+ setShowResumeBubble(false);
+ setAwaitingResponse(true);
+ if (isDraft) {
+ const config: Record = { provider, model, mode };
+ if (session?.system_prompt) config.system_prompt = session.system_prompt;
+ if (session?.target_directory) config.target_directory = session.target_directory;
+ dispatch(
+ launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, provider, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds })
+ ).then((action) => {
+ if (launchAndSendFirstMessage.fulfilled.match(action)) {
+ const realId = action.payload.session.id;
+ dispatch(generateTitle({ sessionId: realId, prompt: msg.prompt }));
+ if (msg.selectedBrowserIds?.length) {
+ dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: realId, label: 'Use Browser' }));
+ }
+ }
+ });
+ } else {
+ if (msg.selectedBrowserIds?.length) {
+ dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id, label: 'Use Browser' }));
+ }
+ dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, provider, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds }))
+ .then((action) => {
+ if (sendMessageThunk.rejected.match(action)) {
+ setAwaitingResponse(false);
+ }
+ });
+ }
+ }, [id, isDraft, mode, model, provider, session?.system_prompt, session?.target_directory, dispatch]);
+
+ const agentBusy = awaitingResponse || (!isDraft && (session?.status === 'running' || session?.status === 'waiting_approval'));
+
const prevStatusRef = useRef(session?.status);
useEffect(() => {
const prev = prevStatusRef.current;
const curr = session?.status;
prevStatusRef.current = curr;
- if (prev === 'running' && (curr === 'completed' || curr === 'stopped' || curr === 'error')) {
- if (id) dispatch(clearGlowingBrowserCards(id));
+ let didDispatchQueued = false;
+
+ const wasActive = prev === 'running' || prev === 'waiting_approval';
+ const isTerminal = curr === 'completed' || curr === 'stopped' || curr === 'error';
+
+ if (wasActive && isTerminal) {
+ if (id) {
+ dispatch(fadeGlowingBrowserCards(id));
+ setTimeout(() => dispatch(clearGlowingBrowserCards(id)), 2800);
+ }
+
+ const nextQueued = messageQueueRef.current.shift();
+ if (nextQueued) {
+ setQueueLength(messageQueueRef.current.length);
+ dispatchMessage(nextQueued);
+ didDispatchQueued = true;
+ } else {
+ if (curr === 'stopped') {
+ setShowResumeBubble(true);
+ }
+ }
+
const currentMode = modesMap[mode];
if (currentMode?.default_next_mode && modesMap[currentMode.default_next_mode]) {
setMode(currentMode.default_next_mode);
@@ -172,7 +265,13 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
}
}
}
- }, [session?.status, mode, modesMap, id, isDraft, dispatch]);
+ if (curr === 'running') {
+ setShowResumeBubble(false);
+ }
+ if (curr !== 'draft' && !didDispatchQueued) {
+ setAwaitingResponse(false);
+ }
+ }, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage]);
const SCROLL_THRESHOLD = 50;
@@ -201,27 +300,13 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
const handleSend = (prompt: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>, forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => {
if (!id) return;
- if (isDraft) {
- const config: Record = { model, mode };
- if (session?.system_prompt) config.system_prompt = session.system_prompt;
- if (session?.target_directory) config.target_directory = session.target_directory;
- dispatch(
- launchAndSendFirstMessage({ draftId: id, config, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills })
- ).then((action) => {
- if (launchAndSendFirstMessage.fulfilled.match(action)) {
- const realId = action.payload.session.id;
- dispatch(generateTitle({ sessionId: realId, prompt }));
- if (selectedBrowserIds?.length) {
- dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: realId }));
- }
- }
- });
- } else {
- if (selectedBrowserIds?.length) {
- dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: id }));
- }
- dispatch(sendMessageThunk({ sessionId: id, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills }));
+ const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds };
+ if (agentBusy) {
+ messageQueueRef.current.push(msg);
+ setQueueLength(messageQueueRef.current.length);
+ return;
}
+ dispatchMessage(msg);
};
const handleModeChange = useCallback((newMode: string) => {
@@ -229,6 +314,11 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
if (id && !isDraft) dispatch(updateSessionMode({ sessionId: id, mode: newMode }));
}, [id, isDraft, dispatch]);
+ const handleProviderChange = useCallback((newProvider: string) => {
+ setProvider(newProvider);
+ if (id && !isDraft) dispatch(updateSessionProvider({ sessionId: id, provider: newProvider }));
+ }, [id, isDraft, dispatch]);
+
const handleModelChange = useCallback((newModel: string) => {
setModel(newModel);
if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel }));
@@ -247,14 +337,34 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
dispatch(stopAgent({ sessionId: id }));
};
- const handleEdit = useCallback(
+ const handleResume = useCallback(() => {
+ if (!id) return;
+ setShowResumeBubble(false);
+ dispatch(sendMessageThunk({
+ sessionId: id,
+ prompt: "Continue where you left off. Start you're response EXACTLY with 'Sorry, let me pick up where I left off",
+ mode,
+ model,
+ provider,
+ hidden: true,
+ }));
+ }, [id, mode, model, provider, dispatch]);
+
+ const [editingMessageId, setEditingMessageId] = useState(null);
+
+ const handleSaveEdit = useCallback(
(messageId: string, newContent: string) => {
if (!id) return;
dispatch(editMessage({ sessionId: id, messageId, content: newContent }));
+ setEditingMessageId(null);
},
[id, dispatch]
);
+ const handleCancelEdit = useCallback(() => {
+ setEditingMessageId(null);
+ }, []);
+
const activeBranchMessages = useMemo(() => {
if (!session) return [];
const branchId = session.active_branch_id || 'main';
@@ -264,16 +374,81 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
return session.messages.filter((m) => m.branch_id === 'main' || m.branch_id === branchId);
}
- const forkIdx = session.messages.findIndex((m) => m.id === branch.fork_point_message_id);
- const preMessages = session.messages
- .slice(0, forkIdx)
- .filter((m) => m.branch_id === (branch.parent_branch_id || 'main'));
- const branchMessages = session.messages.filter((m) => m.branch_id === branchId);
- return [...preMessages, ...branchMessages];
+ const segments: Array<{ branchId: string; upToMessageId?: string }> = [];
+ let cur = branch;
+ let curId = branchId;
+ while (cur && cur.fork_point_message_id) {
+ segments.unshift({ branchId: curId, upToMessageId: cur.fork_point_message_id });
+ curId = cur.parent_branch_id || 'main';
+ cur = session.branches?.[curId];
+ }
+ segments.unshift({ branchId: curId });
+
+ const result: typeof session.messages = [];
+ for (let i = 0; i < segments.length; i++) {
+ const seg = segments[i];
+ const nextForkMsgId = seg.upToMessageId;
+ if (nextForkMsgId) {
+ const forkIdx = session.messages.findIndex((m) => m.id === nextForkMsgId);
+ const pre = session.messages
+ .slice(0, forkIdx)
+ .filter((m) => m.branch_id === seg.branchId);
+ result.push(...pre);
+ } else if (i < segments.length - 1) {
+ const nextFork = segments[i + 1].upToMessageId;
+ const forkIdx = nextFork
+ ? session.messages.findIndex((m) => m.id === nextFork)
+ : session.messages.length;
+ result.push(
+ ...session.messages.slice(0, forkIdx).filter((m) => m.branch_id === seg.branchId)
+ );
+ } else {
+ result.push(...session.messages.filter((m) => m.branch_id === seg.branchId));
+ }
+ }
+ const leafMsgs = session.messages.filter((m) => m.branch_id === branchId);
+ if (!result.some((m) => m.branch_id === branchId)) {
+ result.push(...leafMsgs);
+ }
+ return result;
}, [session?.messages, session?.active_branch_id, session?.branches]);
+ const handleRegenerate = useCallback(
+ (assistantMsg: AgentMessage) => {
+ if (!id) return;
+ const idx = activeBranchMessages.findIndex((m) => m.id === assistantMsg.id);
+ for (let i = idx - 1; i >= 0; i--) {
+ if (activeBranchMessages[i].role === 'user') {
+ const userMsg = activeBranchMessages[i];
+ const content = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content);
+ dispatch(editMessage({ sessionId: id, messageId: userMsg.id, content }));
+ break;
+ }
+ }
+ },
+ [id, activeBranchMessages, dispatch]
+ );
+
+ const handleBranchChat = useCallback(async (upToMessageId: string) => {
+ if (!id) return;
+ const dashId = session?.dashboard_id;
+ const action = await dispatch(duplicateSession({ sessionId: id, dashboardId: dashId, upToMessageId }));
+ if (duplicateSession.fulfilled.match(action)) {
+ if (onBranch) {
+ onBranch(action.payload.id);
+ } else {
+ dispatch(setActiveSession(action.payload.id));
+ }
+ }
+ }, [id, dispatch, onBranch, session?.dashboard_id]);
+
const contextEstimate = useMemo(() => {
- const limit = CONTEXT_WINDOWS[model] || 200_000;
+ // Look up context window from dynamic models first, then fall back to defaults
+ let limit = CONTEXT_WINDOWS_DEFAULT[model] || 200_000;
+ for (const models of Object.values(modelsByProvider)) {
+ const found = models.find((m: any) => m.value === model);
+ if (found?.context_window) { limit = found.context_window; break; }
+ }
let totalChars = 0;
if (session?.system_prompt) totalChars += session.system_prompt.length;
for (const msg of activeBranchMessages) {
@@ -284,7 +459,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
}
const used = Math.round(totalChars / 4);
return { used, limit };
- }, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model]);
+ }, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model, modelsByProvider]);
const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval';
@@ -373,13 +548,33 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
items.push(...outputItems);
} else {
- items.push(msg);
+ if (!msg.hidden) {
+ items.push(msg);
+ }
i++;
}
}
return items;
}, [activeBranchMessages]);
+ const lastAssistantIdsInTurn = useMemo(() => {
+ const ids = new Set();
+ let lastAssistantId: string | null = null;
+ for (const item of renderItems) {
+ if (!isToolGroup(item) && !isToolPair(item)) {
+ const msg = item as AgentMessage;
+ if (msg.role === 'assistant') {
+ lastAssistantId = msg.id;
+ } else if (msg.role === 'user') {
+ if (lastAssistantId) ids.add(lastAssistantId);
+ lastAssistantId = null;
+ }
+ }
+ }
+ if (lastAssistantId) ids.add(lastAssistantId);
+ return ids;
+ }, [renderItems]);
+
const groupMetaRequestedRef = useRef>(new Set());
const groupMetaRefinedRef = useRef>(new Set());
@@ -427,11 +622,33 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
const getSiblingBranches = useCallback(
(messageId: string): string[] => {
if (!session?.branches) return [];
- return Object.values(session.branches)
+
+ const directForks = Object.values(session.branches)
.filter((b) => b.fork_point_message_id === messageId)
.map((b) => b.id);
+ if (directForks.length > 0) {
+ const originalMsg = session.messages.find((m) => m.id === messageId);
+ const parentBranchId = originalMsg?.branch_id || 'main';
+ return [parentBranchId, ...directForks];
+ }
+
+ const msg = session.messages.find((m) => m.id === messageId);
+ if (!msg || msg.role !== 'user') return [];
+ const msgBranch = session.branches[msg.branch_id];
+ if (!msgBranch?.fork_point_message_id) return [];
+ const branchUserMsgs = session.messages.filter(
+ (m) => m.branch_id === msg.branch_id && m.role === 'user'
+ );
+ if (branchUserMsgs.length === 0 || branchUserMsgs[0].id !== messageId) return [];
+
+ const forkPointId = msgBranch.fork_point_message_id;
+ const siblingBranches = Object.values(session.branches)
+ .filter((b) => b.fork_point_message_id === forkPointId)
+ .map((b) => b.id);
+ const parentBranchId = msgBranch.parent_branch_id || 'main';
+ return [parentBranchId, ...siblingBranches];
},
- [session?.branches]
+ [session?.branches, session?.messages]
);
if (!session) {
@@ -527,37 +744,55 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
{renderItems.map((item) => {
if (isToolGroup(item)) {
const groupMeta = session.tool_group_meta?.[item.id];
- return ;
+ return ;
}
if (isToolPair(item)) {
const isPending = item.result === null && sessionRunning;
- return ;
+ return ;
}
const msg = item;
+ const isEditing = editingMessageId === msg.id;
const siblings = getSiblingBranches(msg.id);
const hasBranches = siblings.length > 0;
const currentBranchIdx = hasBranches
? siblings.indexOf(session.active_branch_id || 'main')
: 0;
+ const rawText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
return (
-
-
- {hasBranches && (
- {
- const prevBranch = siblings[Math.max(0, currentBranchIdx - 1)];
- if (prevBranch && id) dispatch(switchBranch({ sessionId: id, branchId: prevBranch }));
- }}
- onNext={() => {
- const nextBranch = siblings[Math.min(siblings.length - 1, currentBranchIdx + 1)];
- if (nextBranch && id) dispatch(switchBranch({ sessionId: id, branchId: nextBranch }));
- }}
+
+
+ {!isEditing && (msg.role === 'user' || (msg.role === 'assistant' && lastAssistantIdsInTurn.has(msg.id))) && (
+ navigator.clipboard.writeText(rawText)}
+ onEdit={msg.role === 'user' ? () => setEditingMessageId(msg.id) : undefined}
+ onRegenerate={msg.role === 'assistant' ? () => handleRegenerate(msg) : undefined}
+ onBranch={msg.role === 'assistant' ? () => handleBranchChat(msg.id) : undefined}
+ branchNav={
+ hasBranches
+ ? {
+ currentIndex: Math.max(0, currentBranchIdx),
+ totalBranches: siblings.length,
+ onPrevious: () => {
+ const prevBranch = siblings[Math.max(0, currentBranchIdx - 1)];
+ if (prevBranch && id) dispatch(switchBranch({ sessionId: id, branchId: prevBranch }));
+ },
+ onNext: () => {
+ const nextBranch = siblings[Math.min(siblings.length - 1, currentBranchIdx + 1)];
+ if (nextBranch && id) dispatch(switchBranch({ sessionId: id, branchId: nextBranch }));
+ },
+ }
+ : undefined
+ }
/>
)}
-
+
);
})}
{session.streamingMessage && (
@@ -566,6 +801,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
key={`streaming-${session.streamingMessage.id}`}
isStreaming
isPending
+ sessionId={session.id}
call={{
id: session.streamingMessage.id,
role: 'tool_call',
@@ -590,9 +826,37 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
/>
)
)}
- {session.status === 'running' && !session.streamingMessage && (
+ {(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && (
)}
+ {showResumeBubble && session.status === 'stopped' && (
+
+
+
+
+ Resume Agent Response
+
+
+
+ )}
{showScrollButton && (
@@ -627,19 +891,264 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
))
)}
-
+ {isGlowing ? (
+ { e.stopPropagation(); onDismissGlow?.(); }}
+ sx={{
+ mx: 1.5,
+ mb: 1.5,
+ py: 1.25,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: 2.5,
+ cursor: 'pointer',
+ fontWeight: 600,
+ fontSize: '0.85rem',
+ color: c.accent.primary,
+ border: `1.5px solid ${c.accent.primary}`,
+ background: `${c.accent.primary}08`,
+ boxShadow: `0 0 12px ${c.accent.primary}25, inset 0 0 12px ${c.accent.primary}08`,
+ animation: 'continue-chat-glow 2s ease-in-out infinite',
+ transition: 'background 0.15s, box-shadow 0.15s',
+ '@keyframes continue-chat-glow': {
+ '0%, 100%': {
+ boxShadow: `0 0 12px ${c.accent.primary}25, inset 0 0 12px ${c.accent.primary}08`,
+ },
+ '50%': {
+ boxShadow: `0 0 20px ${c.accent.primary}40, inset 0 0 20px ${c.accent.primary}15`,
+ },
+ },
+ '&:hover': {
+ background: `${c.accent.primary}14`,
+ boxShadow: `0 0 24px ${c.accent.primary}50, inset 0 0 20px ${c.accent.primary}18`,
+ },
+ }}
+ >
+ Continue chat
+
+ ) : (
+ { if (queueExpanded) { setQueueExpanded(false); setEditingQueueIdx(null); } }}>
+
+ {queueLength > 0 && (
+
+ { setQueueExpanded((v) => !v); setEditingQueueIdx(null); }}
+ sx={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 0.5,
+ px: 1.25,
+ py: 0.25,
+ borderRadius: '8px 8px 0 0',
+ bgcolor: c.bg.surface,
+ border: `1px solid ${c.border.subtle}`,
+ borderBottom: 'none',
+ cursor: 'pointer',
+ userSelect: 'none',
+ '&:hover': { bgcolor: c.bg.secondary },
+ transition: 'background 0.12s',
+ }}
+ >
+ {queueExpanded
+ ?
+ :
+ }
+
+ {queueLength} queued
+
+
+ { e.stopPropagation(); messageQueueRef.current = []; setQueueLength(0); setQueueExpanded(false); setEditingQueueIdx(null); }}
+ sx={{ p: 0.15, color: c.text.tertiary, '&:hover': { color: c.status.error } }}
+ >
+
+
+
+
+
+ {queueExpanded && (
+
+ {messageQueueRef.current.map((msg, idx) => (
+ {
+ setDragIdx(idx);
+ e.dataTransfer.effectAllowed = 'move';
+ }}
+ onDragOver={(e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ if (dragIdx !== null && dragIdx !== idx) setDropTargetIdx(idx);
+ }}
+ onDragLeave={() => { if (dropTargetIdx === idx) setDropTargetIdx(null); }}
+ onDrop={(e) => {
+ e.preventDefault();
+ if (dragIdx !== null && dragIdx !== idx) {
+ const q = messageQueueRef.current;
+ const [item] = q.splice(dragIdx, 1);
+ q.splice(idx, 0, item);
+ setQueueLength(q.length);
+ }
+ setDragIdx(null);
+ setDropTargetIdx(null);
+ }}
+ onDragEnd={() => { setDragIdx(null); setDropTargetIdx(null); }}
+ sx={{
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 0.75,
+ px: 1.5,
+ py: 1,
+ borderBottom: idx < queueLength - 1 ? `1px solid ${c.border.subtle}` : 'none',
+ '&:hover': { bgcolor: c.bg.secondary },
+ transition: 'background 0.1s, opacity 0.15s',
+ ...(dragIdx === idx ? { opacity: 0.35 } : {}),
+ ...(dropTargetIdx === idx && dragIdx !== null && dragIdx !== idx
+ ? { borderTop: `2px solid ${c.accent.primary}` }
+ : {}),
+ }}
+ >
+
+
+
+ {editingQueueIdx === idx ? (
+
+ setEditingQueueText(e.target.value)}
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ const trimmed = editingQueueText.trim();
+ if (trimmed) {
+ messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed };
+ setQueueLength(messageQueueRef.current.length);
+ }
+ setEditingQueueIdx(null);
+ }
+ if (e.key === 'Escape') setEditingQueueIdx(null);
+ }}
+ sx={{
+ '& .MuiOutlinedInput-root': {
+ fontSize: '0.78rem',
+ color: c.text.primary,
+ '& fieldset': { borderColor: c.border.medium },
+ '&.Mui-focused fieldset': { borderColor: c.accent.primary },
+ },
+ }}
+ />
+ {
+ const trimmed = editingQueueText.trim();
+ if (trimmed) {
+ messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed };
+ setQueueLength(messageQueueRef.current.length);
+ }
+ setEditingQueueIdx(null);
+ }}
+ sx={{ p: 0.25, color: c.accent.primary, mt: 0.25 }}
+ >
+
+
+
+ ) : (
+
+ {msg.prompt}
+
+ )}
+ {editingQueueIdx !== idx && (
+
+
+ { setEditingQueueIdx(idx); setEditingQueueText(msg.prompt); }}
+ sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
+ >
+
+
+
+
+ {
+ messageQueueRef.current.splice(idx, 1);
+ setQueueLength(messageQueueRef.current.length);
+ if (messageQueueRef.current.length === 0) setQueueExpanded(false);
+ }}
+ sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.status.error } }}
+ >
+
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+ )}
);
diff --git a/frontend/src/app/pages/AgentChat/ApprovalBar.tsx b/frontend/src/app/pages/AgentChat/ApprovalBar.tsx
index 5cb1ba71..6e703f03 100644
--- a/frontend/src/app/pages/AgentChat/ApprovalBar.tsx
+++ b/frontend/src/app/pages/AgentChat/ApprovalBar.tsx
@@ -59,14 +59,14 @@ const INTEGRATION_META: Record = {
// MCP tool name parser
// ---------------------------------------------------------------------------
-interface ParsedTool {
+export interface ParsedTool {
isMcp: boolean;
serverSlug: string;
actionName: string;
displayName: string;
}
-function parseMcpToolName(rawName: string): ParsedTool {
+export function parseMcpToolName(rawName: string): ParsedTool {
const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/);
if (!m) {
return { isMcp: false, serverSlug: '', actionName: rawName, displayName: rawName };
@@ -93,7 +93,7 @@ interface McpToolMeta {
serverLabel: string;
}
-function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
+export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
const toolItems = useAppSelector((s) => s.tools.items);
return useMemo(() => {
@@ -185,7 +185,7 @@ interface Props {
onDeny: (requestId: string, message?: string) => void;
}
-function getToolIcon(toolName: string) {
+export function getToolIcon(toolName: string) {
switch (toolName) {
case 'Bash': return ;
case 'Read': return ;
diff --git a/frontend/src/app/pages/AgentChat/BranchNavigator.tsx b/frontend/src/app/pages/AgentChat/BranchNavigator.tsx
index 4dbabb95..e506f8c5 100644
--- a/frontend/src/app/pages/AgentChat/BranchNavigator.tsx
+++ b/frontend/src/app/pages/AgentChat/BranchNavigator.tsx
@@ -21,31 +21,38 @@ const BranchNavigator: React.FC = ({ currentIndex, totalBranches, onPrevi
-
-
-
-
- {currentIndex + 1}/{totalBranches}
-
-
-
-
+
+
+
+
+ {currentIndex + 1} / {totalBranches}
+
+
+
+
+
);
};
diff --git a/frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx b/frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx
new file mode 100644
index 00000000..010c1782
--- /dev/null
+++ b/frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx
@@ -0,0 +1,403 @@
+import React, { useEffect, useRef, useMemo } from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
+import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
+import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
+import LanguageIcon from '@mui/icons-material/Language';
+import OpenInNewIcon from '@mui/icons-material/OpenInNew';
+import TouchAppOutlinedIcon from '@mui/icons-material/TouchAppOutlined';
+import KeyboardOutlinedIcon from '@mui/icons-material/KeyboardOutlined';
+import CameraAltOutlinedIcon from '@mui/icons-material/CameraAltOutlined';
+import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined';
+import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
+import CodeOutlinedIcon from '@mui/icons-material/CodeOutlined';
+import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined';
+import { createSelector } from '@reduxjs/toolkit';
+import { useAppSelector, useAppDispatch } from '@/shared/hooks';
+import { AgentMessage, AgentSession, fetchBrowserAgentChildren } from '@/shared/state/agentsSlice';
+import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
+import type { RootState } from '@/shared/state/store';
+
+interface Props {
+ parentSessionId: string;
+ browserId?: string;
+}
+
+interface FeedEntry {
+ type: 'thought' | 'action' | 'result' | 'system';
+ text: string;
+ actionTool?: string;
+ sessionLabel?: string;
+}
+
+function formatMessage(msg: AgentMessage): FeedEntry | null {
+ if (msg.role === 'user') return null;
+
+ if (msg.role === 'assistant' && typeof msg.content === 'string') {
+ const trimmed = msg.content.trim();
+ if (!trimmed) return null;
+ return { type: 'thought', text: trimmed };
+ }
+
+ if (msg.role === 'tool_call') {
+ const content =
+ typeof msg.content === 'string'
+ ? (() => { try { return JSON.parse(msg.content); } catch { return {}; } })()
+ : msg.content;
+ const tool = content?.tool || content?.name || '?';
+ const input = content?.input || {};
+ let brief = '';
+ switch (tool) {
+ case 'BrowserNavigate':
+ brief = `Navigate → ${input.url || '...'}`;
+ break;
+ case 'BrowserClick':
+ brief = `Click ${input.selector || '...'}`;
+ break;
+ case 'BrowserType': {
+ const txt = (input.text || '').slice(0, 40);
+ const ellipsis = (input.text || '').length > 40 ? '…' : '';
+ brief = `Type "${txt}${ellipsis}" into ${input.selector || '...'}`;
+ break;
+ }
+ case 'BrowserScreenshot':
+ brief = 'Screenshot';
+ break;
+ case 'BrowserGetText':
+ brief = 'Read page text';
+ break;
+ case 'BrowserGetElements':
+ brief = `Inspect elements${input.selector ? ` (${input.selector})` : ''}`;
+ break;
+ case 'BrowserEvaluate':
+ brief = `Evaluate JS`;
+ break;
+ default:
+ brief = `${tool}(${JSON.stringify(input).slice(0, 60)})`;
+ }
+ return { type: 'action', text: brief, actionTool: tool };
+ }
+
+ if (msg.role === 'tool_result') {
+ const content =
+ typeof msg.content === 'string'
+ ? (() => { try { return JSON.parse(msg.content); } catch { return { text: msg.content }; } })()
+ : msg.content;
+ const toolName = content?.tool_name || '';
+ const elapsed = content?.elapsed_ms;
+ const text = content?.text || '';
+
+ if (toolName === 'BrowserScreenshot') {
+ return { type: 'result', text: `Screenshot captured${elapsed ? ` (${elapsed}ms)` : ''}` };
+ }
+ const preview = text.length > 120 ? text.slice(0, 120) + '…' : text;
+ return { type: 'result', text: `${preview}${elapsed ? ` (${elapsed}ms)` : ''}` };
+ }
+
+ if (msg.role === 'system') {
+ return { type: 'system', text: typeof msg.content === 'string' ? msg.content : '' };
+ }
+
+ return null;
+}
+
+type SvgIconComponent = typeof OpenInNewIcon;
+
+function getActionIcon(tool?: string): SvgIconComponent {
+ switch (tool) {
+ case 'BrowserNavigate': return OpenInNewIcon;
+ case 'BrowserClick': return TouchAppOutlinedIcon;
+ case 'BrowserType': return KeyboardOutlinedIcon;
+ case 'BrowserScreenshot': return CameraAltOutlinedIcon;
+ case 'BrowserGetText': return ArticleOutlinedIcon;
+ case 'BrowserGetElements': return AccountTreeOutlinedIcon;
+ case 'BrowserEvaluate': return CodeOutlinedIcon;
+ default: return BuildOutlinedIcon;
+ }
+}
+
+interface FeedColors {
+ thought: string;
+ thoughtIcon: string;
+ result: string;
+ error: string;
+ errorIcon: string;
+ scrollThumb: string;
+}
+
+const darkFeedColors: FeedColors = {
+ thought: '#a0aab8',
+ thoughtIcon: '#555b6e',
+ result: '#555b6e',
+ error: '#ff8787',
+ errorIcon: '#ff8787',
+ scrollThumb: '#2a2d3e',
+};
+
+const lightFeedColors: FeedColors = {
+ thought: '#555550',
+ thoughtIcon: '#9e9c95',
+ result: '#9e9c95',
+ error: '#c03030',
+ errorIcon: '#c03030',
+ scrollThumb: '#ccc9c0',
+};
+
+const selectBrowserSessions = createSelector(
+ [(state: RootState) => state.agents.sessions,
+ (_: RootState, parentSessionId: string) => parentSessionId,
+ (_: RootState, __: string, browserId?: string) => browserId],
+ (sessions, parentSessionId, browserId) =>
+ Object.values(sessions).filter(
+ (s): s is AgentSession =>
+ s.mode === 'browser-agent' &&
+ s.parent_session_id === parentSessionId &&
+ (!browserId || s.browser_id === browserId),
+ ),
+);
+
+const BrowserAgentInlineFeed: React.FC = ({ parentSessionId, browserId }) => {
+ const c = useClaudeTokens();
+ const dispatch = useAppDispatch();
+ const { mode } = useThemeMode();
+ const fc = mode === 'dark' ? darkFeedColors : lightFeedColors;
+ const scrollRef = useRef(null);
+ const fetchedForSession = useRef(null);
+
+ const browserSessions = useAppSelector((state) =>
+ selectBrowserSessions(state, parentSessionId, browserId),
+ );
+
+ useEffect(() => {
+ if (browserSessions.length === 0 && fetchedForSession.current !== parentSessionId) {
+ fetchedForSession.current = parentSessionId;
+ dispatch(fetchBrowserAgentChildren(parentSessionId))
+ .unwrap()
+ .catch(() => { fetchedForSession.current = null; });
+ }
+ }, [browserSessions.length, parentSessionId, dispatch]);
+
+ const sessionsWithEntries = useMemo(() => {
+ return browserSessions.map((session) => {
+ const entries: FeedEntry[] = [];
+ for (const msg of session.messages) {
+ const entry = formatMessage(msg);
+ if (entry) entries.push(entry);
+ }
+ if (session.streamingMessage?.role === 'assistant' && session.streamingMessage.content) {
+ entries.push({ type: 'thought', text: session.streamingMessage.content });
+ }
+ return { session, entries };
+ });
+ }, [browserSessions]);
+
+ const totalMessages = browserSessions.reduce(
+ (n, s) => n + s.messages.length + (s.streamingMessage ? 1 : 0),
+ 0,
+ );
+
+ useEffect(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ }
+ }, [totalMessages]);
+
+ if (browserSessions.length === 0) return null;
+
+ const showLabels = sessionsWithEntries.length > 1;
+ const accentColor = c.accent.primary;
+
+ return (
+
+ {sessionsWithEntries.map(({ session, entries }, si) => (
+
+ {showLabels && (
+ 0 ? 1 : 0, mb: 0.25 }}>
+
+
+ {session.browser_id || `Browser ${si + 1}`}
+
+
+
+ )}
+
+ {!showLabels && entries.length === 0 && session.status === 'running' && (
+
+ Starting browser agent...
+
+ )}
+
+ {entries.map((entry, i) => (
+
+ ))}
+
+ {!showLabels && session.status === 'running' && entries.length > 0 && (
+
+
+
+ )}
+
+ ))}
+
+ );
+};
+
+const EntryRow: React.FC<{ entry: FeedEntry; accentColor: string; fc: FeedColors }> = ({ entry, accentColor, fc }) => {
+ const c = useClaudeTokens();
+
+ if (entry.type === 'thought') {
+ return (
+
+
+
+ {entry.text}
+
+
+ );
+ }
+
+ if (entry.type === 'action') {
+ const ActionIcon = getActionIcon(entry.actionTool);
+ return (
+
+
+
+ {entry.text}
+
+
+ );
+ }
+
+ if (entry.type === 'result') {
+ return (
+
+
+ ↳ {entry.text}
+
+
+ );
+ }
+
+ if (entry.type === 'system') {
+ return (
+
+
+
+ {entry.text}
+
+
+ );
+ }
+
+ return null;
+};
+
+const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => {
+ const c = useClaudeTokens();
+ if (status === 'running') {
+ return (
+
+ );
+ }
+ if (status === 'completed') {
+ return ;
+ }
+ if (status === 'error') {
+ return ;
+ }
+ return null;
+};
+
+export default React.memo(BrowserAgentInlineFeed);
diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx
index ff028b5d..fd0f058e 100644
--- a/frontend/src/app/pages/AgentChat/ChatInput.tsx
+++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx
@@ -26,6 +26,7 @@ import AttachFileIcon from '@mui/icons-material/AttachFile';
import AdsClickIcon from '@mui/icons-material/AdsClick';
import CommandPicker, { CommandPickerItem, getToolGroupIcon } from '@/app/components/CommandPicker';
import { useElementSelection, SelectedElement } from '@/app/components/ElementSelectionContext';
+import { getClipboardCards, clearClipboard } from '@/shared/dashboardClipboard';
import { getWebview } from '@/shared/browserRegistry';
import { API_BASE } from '@/shared/config';
import { ContextPath } from '@/app/components/DirectoryBrowser';
@@ -66,6 +67,8 @@ interface Props {
onModeChange: (mode: string) => void;
model: string;
onModelChange: (model: string) => void;
+ provider?: string;
+ onProviderChange?: (provider: string) => void;
isRunning?: boolean;
onStop?: () => void;
autoRunMode?: boolean;
@@ -73,6 +76,7 @@ interface Props {
embedded?: boolean;
autoFocus?: boolean;
sessionId?: string;
+ queueLength?: number;
}
export interface ChatInputHandle {
@@ -90,10 +94,10 @@ const ICON_MAP: Record = {
const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy };
-const MODEL_OPTIONS = [
- { value: 'sonnet', label: 'Sonnet', version: '4.6' },
- { value: 'opus', label: 'Opus', version: '4.6' },
- { value: 'haiku', label: 'Haiku', version: '3.5' },
+const FALLBACK_MODELS = [
+ { value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000 },
+ { value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000 },
+ { value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000 },
];
function formatTokenCount(n: number): string {
@@ -130,7 +134,7 @@ const ContextRing: React.FC<{ used: number; limit: number; accentColor: string;
);
};
-const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId }, ref) => {
+const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => {
const c = useClaudeTokens();
const editorRef = useRef(null);
const containerRef = useRef(null);
@@ -138,6 +142,9 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
const dispatch = useAppDispatch();
const elementSelection = useElementSelection();
+ const fallbackOwnerIdRef = useRef(`input-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`);
+ const ownerId = sessionId || fallbackOwnerIdRef.current;
+
useEffect(() => {
if (autoFocus) editorRef.current?.focus();
}, [autoFocus]);
@@ -153,6 +160,24 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
const skills = useAppSelector((state) => state.skills.items);
const modesMap = useAppSelector((state) => state.modes.items);
const modesArr = useMemo(() => Object.values(modesMap), [modesMap]);
+ const modelsByProvider = useAppSelector((state) => state.models.byProvider);
+ const modelsLoaded = useAppSelector((state) => state.models.loaded);
+
+ // Build flat model list with provider grouping
+ const allModelOptions = useMemo(() => {
+ if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
+ return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: 'Anthropic' })), grouped: { Anthropic: FALLBACK_MODELS } };
+ }
+ const flat: Array<{ value: string; label: string; context_window: number; provider: string }> = [];
+ const grouped: Record> = {};
+ for (const [prov, models] of Object.entries(modelsByProvider)) {
+ grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000 }));
+ for (const m of models) {
+ flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov });
+ }
+ }
+ return { flat, grouped };
+ }, [modelsByProvider, modelsLoaded]);
useEffect(() => {
if (modesArr.length === 0) dispatch(fetchModes());
@@ -281,7 +306,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
let trimmed = serialized.trim();
if (!trimmed) return;
- const selectedEls = elementSelection?.selectedElements ?? [];
+ const selectedEls = elementSelection?.elementsByOwner?.[ownerId] ?? [];
let allImages = images.length > 0
? images.map(({ data, media_type }) => ({ data, media_type }))
: [];
@@ -298,7 +323,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
lines.push(`${i + 1}. [Browser Card] ${title}`);
lines.push(` browser_id: ${el.semanticData.selectId}`);
if (url) lines.push(` URL: ${url}`);
- lines.push(` (Use BrowserAgent with this browser_id to interact with it)`);
+ lines.push(` (Use BrowserAgent with this browser_id to interact with it, or CreateBrowserAgent for a new browser)`);
} else if (el.semanticType && el.semanticData) {
const typeLabel = {
'agent-card': 'Agent Card',
@@ -317,6 +342,9 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(', ');
if (metaStr) lines.push(` ${metaStr}`);
+ if (el.semanticType === 'agent-card' && selectId) {
+ lines.push(` (Use InvokeAgent with session_id "${selectId}" to query this agent with full conversation context)`);
+ }
} else {
const styleStr = Object.entries(el.computedStyles)
.map(([k, v]) => `${k}: ${v}`)
@@ -359,8 +387,8 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
setForcedTools([]);
setAttachedSkills({});
setHasContent(false);
- elementSelection?.clearSelectedElements();
- }, [disabled, images, contextPaths, forcedTools, onSend, elementSelection]);
+ elementSelection?.clearOwnerElements(ownerId);
+ }, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId]);
const detectTrigger = useCallback(() => {
const result = detectEditorTrigger();
@@ -469,6 +497,41 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
};
const handlePaste = useCallback((e: React.ClipboardEvent) => {
+ const copied = getClipboardCards();
+ if (copied.length > 0 && elementSelection) {
+ e.preventDefault();
+ for (const card of copied) {
+ const semanticTypeMap: Record = {
+ agent: 'agent-card',
+ view: 'view-card',
+ browser: 'browser-card',
+ };
+ const semanticType = semanticTypeMap[card.type];
+ if (!semanticType) continue;
+ const labelMap: Record = {
+ 'agent-card': 'Agent',
+ 'view-card': 'View',
+ 'browser-card': 'Browser',
+ };
+ const semanticLabel = (labelMap[semanticType] || semanticType) + ': ' + card.name;
+ const el: SelectedElement = {
+ id: `sel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ selectorPath: `[data-select-type="${semanticType}"][data-select-id="${card.id}"]`,
+ tagName: 'DIV',
+ className: '',
+ outerHTML: '',
+ computedStyles: {},
+ boundingRect: { x: 0, y: 0, width: 0, height: 0 },
+ semanticType,
+ semanticLabel,
+ semanticData: { ...card.meta, selectId: card.id },
+ };
+ elementSelection.addElementForOwner(ownerId, el);
+ }
+ clearClipboard();
+ return;
+ }
+
const items = e.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
@@ -486,7 +549,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
e.preventDefault();
const plain = e.clipboardData.getData('text/plain');
if (plain) document.execCommand('insertText', false, plain);
- }, [addImageFiles]);
+ }, [addImageFiles, elementSelection, ownerId]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -523,7 +586,8 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: '10px',
- minWidth: 140,
+ minWidth: 180,
+ maxHeight: 400,
boxShadow: c.shadow.lg,
'& .MuiMenuItem-root': {
fontSize: '0.8rem',
@@ -535,7 +599,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
},
};
- const selectedElements = elementSelection?.selectedElements ?? [];
+ const selectedElements = elementSelection?.elementsByOwner?.[ownerId] ?? [];
const hasAttachments = images.length > 0 || contextPaths.length > 0 || forcedTools.length > 0 || selectedElements.length > 0;
return (
@@ -783,7 +847,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
icon={}
label={chipLabel}
size="small"
- onDelete={() => elementSelection?.removeSelectedElement(el.id)}
+ onDelete={() => elementSelection?.removeOwnerElement(ownerId, el.id)}
sx={{
bgcolor: 'rgba(59, 130, 246, 0.1)',
color: '#3b82f6',
@@ -849,7 +913,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
userSelect: 'none',
}}
>
- {disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : `${modeConf.label}, @ for context, / for commands`}
+ {disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued — type another or wait…` : 'Agent is working — messages will queue…') : `${modeConf.label}, @ for context, / for commands`}
)}
@@ -936,7 +1000,7 @@ const ChatInput = forwardRef