diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 46f7f89e..398ffbd8 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -539,6 +539,12 @@ class AgentManager: session.pending_approvals.append(approval_req) session.status = "waiting_approval" + _analytics("approval.requested", { + "tool_name": tool_name, + "is_first_approval_in_session": len(session.pending_approvals) == 1, + "model": session.model, + }, session_id=session_id, dashboard_id=session.dashboard_id) + await ws_manager.send_to_session(session_id, "agent:status", { "session_id": session_id, "status": "waiting_approval", @@ -548,6 +554,15 @@ class AgentManager: session_id, request_id, tool_name, safe_input ) + approval_latency_ms = int((datetime.now() - approval_req.created_at).total_seconds() * 1000) + _analytics("approval.resolved", { + "tool_name": tool_name, + "decision": decision.get("behavior", "unknown"), + "latency_ms": approval_latency_ms, + "input_was_modified": decision.get("updated_input") is not None, + "model": session.model, + }, session_id=session_id, dashboard_id=session.dashboard_id) + session.pending_approvals = [ a for a in session.pending_approvals if a.id != request_id ] @@ -625,6 +640,18 @@ class AgentManager: raw_response = input_data.get("tool_response", "") + # Track individual tool execution + hook_tool_name_early = input_data.get("tool_name", "") + if hook_tool_name_early: + _analytics("tool.executed", { + "tool_name": hook_tool_name_early, + "tool_type": "mcp" if "__" in hook_tool_name_early else "builtin", + "duration_ms": elapsed_ms, + "success": not (isinstance(raw_response, str) and raw_response.startswith("Error")), + "model": session.model, + "provider": session.provider, + }, session_id=session_id, dashboard_id=session.dashboard_id) + if isinstance(raw_response, list) and raw_response: text_parts = [ block.get("text", "") @@ -839,8 +866,11 @@ class AgentManager: from backend.apps.nine_router import is_running as _9r_running if _9r_running(): options_kwargs["env"] = { + "ANTHROPIC_API_KEY": "9router", "ANTHROPIC_BASE_URL": "http://localhost:20128", } + # --bare skips CLI's own OAuth/keychain auth, uses only ANTHROPIC_API_KEY + options_kwargs["extra_args"] = {"bare": None} elif global_settings.anthropic_api_key: options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key} else: @@ -871,6 +901,7 @@ class AgentManager: stream_text_msg_id = None stream_tool_msg_ids_ordered = [] stream_block_index_map = {} + _turn_number = 0 async for message in query( prompt=prompt_stream(), @@ -976,6 +1007,13 @@ class AgentManager: "message": tool_msg.model_dump(mode="json"), }) + _turn_number += 1 + _analytics("turn.completed", { + "turn_number": _turn_number, + "tool_calls_in_turn": len(tool_uses), + "model": session.model, + }, session_id=session_id, dashboard_id=session.dashboard_id) + stream_text_msg_id = None stream_tool_msg_ids_ordered = [] stream_block_index_map = {} @@ -996,6 +1034,13 @@ class AgentManager: except Exception as e: logger.exception(f"Agent {session_id} error: {e}") session.status = "error" + _analytics("session.error", { + "error_type": type(e).__name__, + "error_message": str(e)[:500], + "model": session.model, + "provider": session.provider, + "mode": session.mode, + }, session_id=session_id, dashboard_id=session.dashboard_id) error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id) session.messages.append(error_msg) await ws_manager.send_to_session(session_id, "agent:message", { @@ -1026,6 +1071,12 @@ class AgentManager: "tools_list": list(set(tool_names)), "session_title": session.name, "first_user_message": user_messages[0] if user_messages else "", + "input_tokens": session.tokens.get("input", 0), + "output_tokens": session.tokens.get("output", 0), + "is_sub_agent": session.parent_session_id is not None, + "parent_session_id": session.parent_session_id, + "sub_agent_count": len([s for s in self.sessions.values() if s.parent_session_id == session_id]), + "branch_count": len(session.branches), }, session_id=session_id, dashboard_id=session.dashboard_id) await ws_manager.send_to_session(session_id, "agent:status", { @@ -1193,9 +1244,22 @@ class AgentManager: session_changed = False if model and model != session.model: + _analytics("model.switched", { + "from_model": session.model, + "to_model": model, + "from_provider": session.provider, + "to_provider": provider or session.provider, + "message_number": len([m for m in session.messages if m.role == "user"]), + "cost_so_far": session.cost_usd, + }, session_id=session_id, dashboard_id=session.dashboard_id) session.model = model session_changed = True if mode and mode != session.mode: + _analytics("feature.used", { + "feature": "mode.switched", + "from_mode": session.mode, + "to_mode": mode, + }, session_id=session_id, dashboard_id=session.dashboard_id) session.mode = mode mode_tools, _, _ = self._resolve_mode(mode) session.allowed_tools = mode_tools @@ -1225,6 +1289,34 @@ class AgentManager: "message": user_msg.model_dump(mode="json"), }) + # Track context attachment patterns + if context_paths or attached_skills or images or forced_tools: + _analytics("context.attached", { + "file_count": len([c for c in (context_paths or []) if c.get("type") == "file"]), + "directory_count": len([c for c in (context_paths or []) if c.get("type") == "directory"]), + "skill_count": len(attached_skills or []), + "image_count": len(images or []), + "has_forced_tools": bool(forced_tools), + }, session_id=session_id, dashboard_id=session.dashboard_id) + + # Track skill usage + for skill in (attached_skills or []): + _analytics("feature.used", { + "feature": "skill.used", + "skill_name": skill.get("name", ""), + }, session_id=session_id, dashboard_id=session.dashboard_id) + + # Track first message sophistication + is_first_message = sum(1 for m in session.messages if m.role == "user") == 1 + if is_first_message: + _analytics("session.first_message", { + "message_length": len(prompt), + "has_code_block": "```" in prompt, + "has_url": "http://" in prompt or "https://" in prompt, + "model": session.model, + "mode": session.mode, + }, session_id=session_id, dashboard_id=session.dashboard_id) + session.status = "running" await ws_manager.send_to_session(session_id, "agent:status", { "session_id": session_id, @@ -1317,6 +1409,13 @@ class AgentManager: session.branches[new_branch_id] = new_branch session.active_branch_id = new_branch_id + _analytics("feature.used", { + "feature": "message.branched", + "branch_depth": len([b for b in session.branches.values() if b.parent_branch_id]), + "total_branches_in_session": len(session.branches), + "messages_before_fork": len([m for m in session.messages if m.branch_id == fork_parent_branch]), + }, session_id=session_id, dashboard_id=session.dashboard_id) + edited_msg = Message( role="user", content=new_content, @@ -1592,6 +1691,20 @@ class AgentManager: session = AgentSession(**data) + hours_since_closed = 0 + if data.get("closed_at"): + try: + closed = datetime.fromisoformat(data["closed_at"][:19]) + hours_since_closed = round((datetime.now() - closed).total_seconds() / 3600, 1) + except Exception: + pass + _analytics("session.resumed", { + "hours_since_closed": hours_since_closed, + "original_message_count": len(data.get("messages", [])), + "original_cost_usd": data.get("cost_usd", 0), + "model": session.model, + }, session_id=session_id, dashboard_id=session.dashboard_id) + session.closed_at = None self.sessions[session_id] = session diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 8dda1ff5..fbd14737 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -239,6 +239,9 @@ async def subscriptions_poll(body: dict): code_verifier=body.get("code_verifier"), extra_data=body.get("extra_data"), ) + if result.get("success"): + from backend.apps.analytics.collector import record as _analytics + _analytics("subscription.connected", {"provider": provider}) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -259,6 +262,9 @@ async def subscriptions_exchange(body: dict): try: result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state) + if result.get("success"): + from backend.apps.analytics.collector import record as _analytics + _analytics("subscription.connected", {"provider": provider}) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -290,6 +296,8 @@ async def subscriptions_disconnect(body: dict): if conn and conn.get("id"): async with httpx.AsyncClient(timeout=10.0) as client: await client.delete(f"{NINE_ROUTER_API}/providers/{conn['id']}") + from backend.apps.analytics.collector import record as _analytics + _analytics("subscription.disconnected", {"provider": provider}) return {"ok": True} return {"ok": False, "error": "Connection not found"} except Exception as e: diff --git a/backend/apps/agents/browser_agent.py b/backend/apps/agents/browser_agent.py index 7ac3d2a7..7b9ee097 100644 --- a/backend/apps/agents/browser_agent.py +++ b/backend/apps/agents/browser_agent.py @@ -592,6 +592,13 @@ async def run_browser_agents( Each task dict has: { browser_id (optional), task, url (optional) } Returns a list of result dicts, one per task. """ + from backend.apps.analytics.collector import record as _analytics + _analytics("feature.used", { + "feature": "browser_agent.launched", + "task_count": len(tasks), + "model": model, + }, dashboard_id=dashboard_id) + pre_selected = set(pre_selected_browser_ids or []) async def _run_one(task_def: dict) -> dict: diff --git a/backend/apps/analytics/analytics.py b/backend/apps/analytics/analytics.py index 51926967..04e03958 100644 --- a/backend/apps/analytics/analytics.py +++ b/backend/apps/analytics/analytics.py @@ -1,11 +1,13 @@ """Analytics SubApp: PostHog for product analytics + local usage summary from session data.""" +import asyncio import json import logging import os import platform from collections import Counter from contextlib import asynccontextmanager +from datetime import datetime from backend.config.Apps import SubApp from backend.config.paths import SESSIONS_DIR @@ -13,16 +15,49 @@ from backend.apps.analytics.collector import init as init_collector, shutdown as logger = logging.getLogger(__name__) +APP_VERSION = "1.0.15" + +_heartbeat_task: asyncio.Task | None = None + + +async def _heartbeat_loop(): + """Send a heartbeat event every 60 seconds for usage-time tracking.""" + while True: + await asyncio.sleep(60) + try: + from backend.apps.agents.agent_manager import agent_manager + record("app.heartbeat", { + "active_session_count": len(agent_manager.sessions), + }) + except Exception: + pass + @asynccontextmanager async def analytics_lifespan(): + global _heartbeat_task + init_collector() logger.info("PostHog analytics initialised") try: - from backend.apps.settings.settings import load_settings + from backend.apps.settings.settings import load_settings, _save_settings settings = load_settings() + # Track first open + is_first_open = settings.first_opened_at is None + if is_first_open: + settings.first_opened_at = datetime.now().isoformat() + _save_settings(settings) + + days_since_install = 0 + if settings.first_opened_at: + try: + first = datetime.fromisoformat(settings.first_opened_at[:19]) + days_since_install = (datetime.now() - first).days + except Exception: + pass + providers = [] if getattr(settings, "anthropic_api_key", None): providers.append("anthropic") @@ -40,11 +75,15 @@ async def analytics_lifespan(): "platform": platform.platform(), "provider_count": len(providers), "providers": providers, + "is_first_open": is_first_open, + "days_since_install": days_since_install, + "app_version": APP_VERSION, }) identify({ "providers_configured": providers, "provider_count": len(providers), + "app_version": APP_VERSION, }) except Exception as e: logger.debug(f"Analytics startup event failed (non-critical): {e}") @@ -56,8 +95,20 @@ async def analytics_lifespan(): except Exception as e: logger.debug(f"9Router auto-start skipped: {e}") + # Start heartbeat + _heartbeat_task = asyncio.create_task(_heartbeat_loop()) + yield + # Stop heartbeat + if _heartbeat_task: + _heartbeat_task.cancel() + try: + await _heartbeat_task + except asyncio.CancelledError: + pass + _heartbeat_task = None + # Stop 9Router try: from backend.apps.nine_router import stop as stop_9router @@ -123,7 +174,6 @@ async def usage_summary(): closed = s.get("closed_at") if created and closed: try: - from datetime import datetime c_str = created[:19] cl_str = closed[:19] dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds() @@ -230,3 +280,15 @@ async def cost_breakdown(period: str = "7d"): @analytics.router.get("/status") async def analytics_status(): return {"status": "posthog", "enabled": True} + + +@analytics.router.post("/event") +async def record_event(body: dict): + """Accept analytics events from the frontend (e.g. feature.time_spent).""" + event_type = body.get("event_type", "") + properties = body.get("properties", {}) + if event_type: + record(event_type, properties, + session_id=body.get("session_id"), + dashboard_id=body.get("dashboard_id")) + return {"ok": True} diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 8a6c4d07..311b771f 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -305,6 +305,8 @@ async def create_output(body: OutputCreate): updated_at=now, ) _save(output) + from backend.apps.analytics.collector import record as _analytics + _analytics("feature.used", {"feature": "view.created"}) return {"ok": True, "output": output.model_dump()} @@ -349,6 +351,8 @@ Return ONLY valid JSON with these keys. No markdown fences, no extra text.\ @outputs.router.post("/vibe-code") async def vibe_code(body: VibeCodeRequest): """Use an LLM to generate or iterate on Output code from a natural language prompt.""" + from backend.apps.analytics.collector import record as _analytics + _analytics("feature.used", {"feature": "vibe_code.used"}) try: import anthropic except ImportError: diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 91a9ba56..130d06eb 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -52,6 +52,7 @@ class AppSettings(BaseModel): # Analytics: opted in by default, user can toggle off analytics_opt_in: bool = True installation_id: Optional[str] = None + first_opened_at: Optional[str] = None # ISO timestamp of first app open class CustomProvider(BaseModel): diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index 4ddfd1f8..c2889c8d 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -52,6 +52,39 @@ async def get_settings(): @settings.router.put("") async def update_settings(body: AppSettings): + from backend.apps.analytics.collector import record as _analytics + + old = load_settings() + + # Track provider key changes + provider_keys = { + "anthropic_api_key": "anthropic", + "openai_api_key": "openai", + "google_api_key": "gemini", + "openrouter_api_key": "openrouter", + } + for key, provider_name in provider_keys.items(): + old_val = bool(getattr(old, key, None)) + new_val = bool(getattr(body, key, None)) + if old_val != new_val: + _analytics("provider.configured", { + "provider": provider_name, + "action": "added" if new_val else "removed", + }) + + # Track settings changes (key names only, not values) + old_dict = old.model_dump() + new_dict = body.model_dump() + secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key", + "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", + "copilot_github_token", "copilot_token", "installation_id"} + safe_changed = [ + k for k in new_dict + if k in old_dict and new_dict[k] != old_dict[k] and k not in secret_keys + ] + if safe_changed: + _analytics("settings.changed", {"changed_keys": safe_changed}) + os.makedirs(DATA_DIR, exist_ok=True) with open(SETTINGS_FILE, "w") as f: json.dump(body.model_dump(), f, indent=2) diff --git a/backend/apps/skills/skills.py b/backend/apps/skills/skills.py index 83d868a6..e12e920b 100644 --- a/backend/apps/skills/skills.py +++ b/backend/apps/skills/skills.py @@ -163,6 +163,8 @@ async def create_skill(body: SkillCreate): file_path=fpath, command=body.command or slug, ) + from backend.apps.analytics.collector import record as _analytics + _analytics("feature.used", {"feature": "skill.created"}) return {"ok": True, "skill": skill.model_dump()} diff --git a/backend/apps/templates/templates.py b/backend/apps/templates/templates.py index 36f0adf3..e3d9e96c 100644 --- a/backend/apps/templates/templates.py +++ b/backend/apps/templates/templates.py @@ -62,6 +62,8 @@ async def create_template(body: PromptTemplateCreate): tags=body.tags, ) _save(template) + from backend.apps.analytics.collector import record as _analytics + _analytics("feature.used", {"feature": "template.created"}) return {"ok": True, "template": template.model_dump()} @templates.router.put("/{template_id}") @@ -88,4 +90,6 @@ async def render_template(body: dict): placeholder = "{{" + field.name + "}}" value = values.get(field.name, field.default or "") rendered = rendered.replace(placeholder, str(value)) + from backend.apps.analytics.collector import record as _analytics + _analytics("feature.used", {"feature": "template.used"}) return {"rendered": rendered} diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index b42750d3..c27bb2eb 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -21,8 +21,8 @@ logger = logging.getLogger(__name__) # Default Google OAuth credentials for the OpenSwarm project. # These are public credentials for a desktop/web OAuth client (safe to embed per Google's docs). # Users can override via GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET env vars. -_DEFAULT_GOOGLE_CLIENT_ID = "514323102245-ivhcnod6q9jj16ck1hbaiaelivtgtnd1.apps.googleusercontent.com" -_DEFAULT_GOOGLE_CLIENT_SECRET = "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" +_DEFAULT_GOOGLE_CLIENT_ID = "6741219524-8vpt07arcc5rvkdb4j1b6v9g53469ugq.apps.googleusercontent.com" +_DEFAULT_GOOGLE_CLIENT_SECRET = "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" os.environ.setdefault("GOOGLE_OAUTH_CLIENT_ID", _DEFAULT_GOOGLE_CLIENT_ID) os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", _DEFAULT_GOOGLE_CLIENT_SECRET) @@ -410,6 +410,19 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]: env = config.setdefault("env", {}) env.setdefault("PATH", _augmented_path()) env.setdefault("PYTHONPATH", "") + # Point uv/uvx at our bundled Python — avoids macOS CLT popup on fresh Macs + # and avoids downloading Python at runtime + _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" + if _is_packaged: + _resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + _bundled_python = os.path.join(_resources, "python-env", "bin", "python3") + if os.path.exists(_bundled_python): + env.setdefault("UV_PYTHON", _bundled_python) + else: + _backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + _venv_python = os.path.join(_backend, ".venv", "bin", "python3") + if os.path.exists(_venv_python): + env.setdefault("UV_PYTHON", _venv_python) return config diff --git a/electron/package-lock.json b/electron/package-lock.json index f6ed211e..787a48eb 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.0.14", + "version": "1.0.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.0.14", + "version": "1.0.15", "hasInstallScript": true, "dependencies": { "electron-updater": "^6.3.0", diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx index 2c1838f1..749ce9c5 100644 --- a/frontend/src/app/components/OnboardingModal.tsx +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -29,13 +29,14 @@ const OnboardingModal: React.FC = () => { .then((r) => r.json()) .then((data) => { if (data.running) { - setNineRouterReady(true); // Check if already has subscription const connections = data.providers?.connections || []; if (connections.some((p: any) => p.isActive)) { // Already connected — don't show onboarding return; } + // Delay before marking ready — 9Router's OAuth needs time to warm up + setTimeout(() => setNineRouterReady(true), 3000); } else { attempts++; if (attempts < maxAttempts) { @@ -79,9 +80,16 @@ const OnboardingModal: React.FC = () => { setOpen(false); }; - // Exact same connect logic as Settings/SubscriptionCards + // Same connect logic as Settings/SubscriptionCards const handleConnect = async (providerId: string) => { + // Cancel any previous attempt + if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } + if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } setConnecting(providerId); + + // Delay before calling connect — avoids Claude OAuth rate limit on retries + await new Promise(r => setTimeout(r, 1000)); + try { const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, { method: 'POST', @@ -118,7 +126,7 @@ const OnboardingModal: React.FC = () => { } catch {} }, 5000); pollTimerRef.current = timer; - setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 300000); + setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 30000); } else if (data.flow === 'authorization_code') { const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700'); @@ -174,7 +182,7 @@ const OnboardingModal: React.FC = () => { if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } setConnecting(null); - }, 300000); + }, 30000); } else { setConnecting(null); diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx index 9b8b5f6b..dc009af9 100644 --- a/frontend/src/app/pages/Dashboard/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx @@ -210,6 +210,7 @@ const AgentCard: React.FC = ({ }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); + const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); const cardBoxRef = useRef(null); @@ -783,7 +784,7 @@ const AgentCard: React.FC = ({ {formatDuration(session.created_at, (session as any).closed_at, session.status)} - {session.cost_usd > 0 && ( + {session.cost_usd > 0 && hasApiKey && ( ${session.cost_usd.toFixed(4)} diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 06590832..20f23108 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -223,17 +223,23 @@ const SubscriptionCards: React.FC = () => { }; const handleConnect = async (providerId: string) => { + // Cancel any previous attempt first + if (pollTimer) { clearInterval(pollTimer); setPollTimer(null); } setConnecting(providerId); setUserCode(''); + + // Small delay if retrying — avoids hitting Claude's rate limit + await new Promise(r => setTimeout(r, 500)); + try { const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: providerId }), }); + if (!r.ok) { setConnecting(null); return; } const data = await r.json(); if (data.flow === 'device_code') { - // Device code flow (GitHub, Qwen, etc.) — show code, poll const code = data.user_code || ''; setUserCode(code); if (data.verification_uri) window.open(data.verification_uri, '_blank'); @@ -247,6 +253,7 @@ const SubscriptionCards: React.FC = () => { const pd = await pr.json(); if (pd.success) { clearInterval(timer); + setPollTimer(null); setConnecting(null); setUserCode(''); fetchStatus(); @@ -254,18 +261,36 @@ const SubscriptionCards: React.FC = () => { } catch {} }, 5000); setPollTimer(timer); - setTimeout(() => { clearInterval(timer); setConnecting(null); setUserCode(''); }, 300000); + setTimeout(() => { clearInterval(timer); setPollTimer(null); setConnecting(null); setUserCode(''); }, 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'); + // Status polling as primary detection + const statusPoller = setInterval(async () => { + try { + const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); + const sd = await sr.json(); + const connections = sd.providers?.connections || []; + if (connections.some((p: any) => p.provider === providerId && p.isActive)) { + clearInterval(statusPoller); + setPollTimer(null); + window.removeEventListener('message', msgHandler); + setConnecting(null); + fetchStatus(); + } + } catch {} + }, 2000); + setPollTimer(statusPoller); + + // postMessage listener as secondary (faster when it works) 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); + setPollTimer(null); if (popup && !popup.closed) popup.close(); try { await fetch(`${API_BASE}/agents/subscriptions/exchange`, { @@ -283,21 +308,13 @@ const SubscriptionCards: React.FC = () => { }; window.addEventListener('message', msgHandler); - const statusPoller = setInterval(async () => { - try { - const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); - const sd = await sr.json(); - const connections = sd.providers?.connections || []; - if (connections.some((p: any) => p.provider === providerId && p.isActive)) { - clearInterval(statusPoller); - window.removeEventListener('message', msgHandler); - setConnecting(null); - fetchStatus(); - } - } catch {} - }, 2000); - setPollTimer(statusPoller); - setTimeout(() => { clearInterval(statusPoller); window.removeEventListener('message', msgHandler); setConnecting(null); }, 300000); + // Timeout: reset after 30s so user can try again (not 5min) + setTimeout(() => { + clearInterval(statusPoller); + setPollTimer(null); + window.removeEventListener('message', msgHandler); + setConnecting(null); + }, 30000); } else { setConnecting(null); @@ -435,7 +452,8 @@ const UsageStats: React.FC = () => { if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`; return `${(n / 1_000_000).toFixed(2)}M`; }; - const costSourceLabel = stats.cost_source === '9router' ? 'equivalent API cost' : stats.cost_source === 'sdk' ? 'via API' : ''; + const isSubscription = stats.cost_source === '9router'; + const costSourceLabel = isSubscription ? 'saved with your subscription' : stats.cost_source === 'sdk' ? 'via API' : ''; return ( @@ -449,10 +467,12 @@ const UsageStats: React.FC = () => { - {stats.cost_source === '9router' ? 'Est. API Cost' : 'Total Cost'} + {isSubscription ? 'You Saved' : 'Total Cost'} {formatCost(stats.total_cost_usd)} - {costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'} + {isSubscription + ? `${formatCost(stats.avg_cost_per_session)} avg · saved with your subscription` + : costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'}