diff --git a/package.json b/package.json index c0b98f25..9c547ddf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autoforge-ai", - "version": "0.1.21", + "version": "0.1.22", "description": "Autonomous coding agent with web UI - build complete apps with AI", "license": "AGPL-3.0", "bin": { diff --git a/server/main.py b/server/main.py index 803c1072..1ea36976 100644 --- a/server/main.py +++ b/server/main.py @@ -14,6 +14,18 @@ from contextlib import asynccontextmanager from pathlib import Path +# Fail fast on unsupported Python versions. Older interpreters surface as +# opaque Claude SDK errors (e.g. "Control request timeout: initialize"). +if sys.version_info < (3, 11): + sys.exit( + "ERROR: AutoForge requires Python 3.11 or newer " + f"(you are running Python {sys.version.split()[0]}). " + "Older versions cause opaque Claude SDK errors such as " + "'Control request timeout: initialize'. " + "Recreate your virtual environment with Python 3.11+ " + "(python3.11 -m venv venv && pip install -r requirements.txt)." + ) + # Fix for Windows subprocess support in asyncio if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) @@ -53,6 +65,7 @@ from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks from .services.scheduler_service import cleanup_scheduler, get_scheduler from .services.terminal_manager import cleanup_all_terminals +from .utils.ws_security import WebSocketOriginMiddleware from .websocket import project_websocket # Paths @@ -142,6 +155,13 @@ async def lifespan(app: FastAPI): # Security Middleware # ============================================================================ +# WebSocket Origin validation (CSWSH protection). Starlette's @app.middleware("http") +# never runs for WebSocket handshakes, so the require_localhost guard below does not +# cover WS routes. Browsers do not enforce same-origin on WebSocket connects either, +# so without this check any web page could hijack the terminal/chat sockets. +# Registered unconditionally so it applies even when AUTOFORGE_ALLOW_REMOTE=1. +app.add_middleware(WebSocketOriginMiddleware, allow_remote=ALLOW_REMOTE) + if not ALLOW_REMOTE: @app.middleware("http") async def require_localhost(request: Request, call_next): diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 275a900d..1a16606d 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -28,6 +28,7 @@ from .chat_constants import ( ROOT_DIR, check_rate_limit_error, + format_client_init_error, safe_receive_response, ) @@ -68,6 +69,24 @@ "WebSearch", ] +# Tools explicitly removed from the assistant's toolset. +# allowed_tools only pre-approves permissions; disallowed_tools actually +# removes tools from the model, which matters with bypassPermissions. +DISALLOWED_ASSISTANT_TOOLS = [ + # File mutation / execution + "Write", + "Edit", + "MultiEdit", + "NotebookEdit", + "Bash", + # Feature MCP tools reserved for coding agents + "mcp__features__feature_mark_passing", + "mcp__features__feature_mark_failing", + "mcp__features__feature_mark_in_progress", + "mcp__features__feature_claim_and_get", + "mcp__features__feature_clear_in_progress", +] + def get_system_prompt(project_name: str, project_dir: Path) -> str: """Generate the system prompt for the assistant with project context.""" @@ -105,11 +124,11 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: ## What You CANNOT Do -- Modify, create, or delete source code files -- Mark features as passing (that requires actual implementation by the coding agent) +- Modify, create, or delete source code files -- you have NO Write/Edit/Bash tools +- Mark features as passing or failing (that is the coding agent's job after real implementation) - Run bash commands or execute code -If the user asks you to modify code, explain that you're a project assistant and they should use the main coding agent for implementation. +**You must NEVER implement code yourself.** Even if the user pastes code, asks for a "quick fix", or insists -- do not attempt to write or edit files. Implementation is done exclusively by the coding agents. When the user wants code changed or a bug fixed, create a feature for it with `feature_create` (or `feature_create_bulk`) describing the change, then tell the user to start the coding agent to implement it. ## Project Specification @@ -234,6 +253,7 @@ async def start(self) -> AsyncGenerator[dict, None]: "permissions": { "defaultMode": "bypassPermissions", # Read-only, no dangerous ops "allow": permissions_list, + "deny": ["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"], }, } from autoforge_paths import get_claude_assistant_settings_path @@ -288,6 +308,7 @@ async def start(self) -> AsyncGenerator[dict, None]: # This avoids Windows command line length limit (~8191 chars) setting_sources=["project"], allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], + disallowed_tools=DISALLOWED_ASSISTANT_TOOLS, mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime permission_mode="bypassPermissions", max_turns=100, @@ -302,7 +323,7 @@ async def start(self) -> AsyncGenerator[dict, None]: logger.info("Claude client ready") except Exception as e: logger.exception("Failed to create Claude client") - yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"} + yield {"type": "error", "content": format_client_init_error(e)} return # Send initial greeting only for NEW conversations diff --git a/server/services/chat_constants.py b/server/services/chat_constants.py index 2e832e24..68e70e96 100644 --- a/server/services/chat_constants.py +++ b/server/services/chat_constants.py @@ -67,6 +67,35 @@ def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]: return False, None +def format_client_init_error(exc: Exception) -> str: + """Build an actionable error message for Claude client startup failures. + + The Claude Agent SDK raises an opaque ``Control request timeout: + initialize`` when the Claude CLI subprocess never completes the + initialize handshake (auth problems, broken/missing CLI, PATH issues, + unsupported Python, antivirus interference, slow machines). Translate + that into concrete guidance instead of surfacing the raw error. + """ + if "Control request timeout" in str(exc): + msg = ( + "Claude client timed out during startup (the Claude CLI did not " + "respond to the initialize handshake). Common causes:\n" + "- Claude CLI not authenticated: run `claude login` or set ANTHROPIC_API_KEY\n" + "- Claude CLI not installed or outdated: run `npm install -g @anthropic-ai/claude-code`\n" + "- Antivirus/firewall blocking the CLI subprocess (common on Windows)\n" + "- Slow machine: raise the timeout by setting the environment variable " + "CLAUDE_CODE_STREAM_CLOSE_TIMEOUT=120000 before starting AutoForge" + ) + if sys.version_info < (3, 11): + py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + msg = ( + f"You are running Python {py}, but AutoForge requires Python 3.11+. " + "Recreate your virtual environment with Python 3.11+ and try again.\n\n" + msg + ) + return msg + return f"Failed to initialize Claude: {exc}" + + async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator: """Wrap ``client.receive_response()`` to skip ``MessageParseError``. diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index cb576f1d..66edb94e 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -27,6 +27,7 @@ ROOT_DIR, build_attachment_content_blocks, check_rate_limit_error, + format_client_init_error, make_multimodal_message, safe_receive_response, ) @@ -135,7 +136,7 @@ async def start(self) -> AsyncGenerator[dict, None]: # Create temporary security settings file (unique per session to avoid conflicts) # Note: permission_mode="bypassPermissions" is safe here because: - # 1. Only Read/Glob file tools are allowed (no Write/Edit) + # 1. Write/Edit/Bash are removed via disallowed_tools and the "deny" list below # 2. MCP tools are restricted to feature creation only # 3. No Bash access - cannot execute arbitrary commands security_settings = { @@ -147,6 +148,7 @@ async def start(self) -> AsyncGenerator[dict, None]: "Glob(./**)", *EXPAND_FEATURE_TOOLS, ], + "deny": ["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"], }, } from autoforge_paths import get_expand_settings_path @@ -196,6 +198,7 @@ async def start(self) -> AsyncGenerator[dict, None]: "WebSearch", *EXPAND_FEATURE_TOOLS, ], + disallowed_tools=["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"], mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime permission_mode="bypassPermissions", max_turns=100, @@ -206,11 +209,11 @@ async def start(self) -> AsyncGenerator[dict, None]: ) await self.client.__aenter__() self._client_entered = True - except Exception: + except Exception as e: logger.exception("Failed to create Claude client") yield { "type": "error", - "content": "Failed to initialize Claude" + "content": format_client_init_error(e) } return diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index 377640c4..0788b13a 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -24,6 +24,7 @@ ROOT_DIR, build_attachment_content_blocks, check_rate_limit_error, + format_client_init_error, make_multimodal_message, safe_receive_response, ) @@ -185,7 +186,7 @@ async def start(self) -> AsyncGenerator[dict, None]: logger.exception("Failed to create Claude client") yield { "type": "error", - "content": f"Failed to initialize Claude: {str(e)}" + "content": format_client_init_error(e) } return diff --git a/server/utils/ws_security.py b/server/utils/ws_security.py new file mode 100644 index 00000000..308f5ed0 --- /dev/null +++ b/server/utils/ws_security.py @@ -0,0 +1,142 @@ +""" +WebSocket Origin Validation +=========================== + +Starlette's ``@app.middleware("http")`` decorator only runs for HTTP requests, +so the ``require_localhost`` guard in ``server/main.py`` never sees WebSocket +handshakes. Browsers also do not enforce same-origin policy on WebSocket +connections, which left every WS endpoint (including the interactive PTY +terminal) open to cross-site WebSocket hijacking (CSWSH): any malicious web +page the user visited could open ``ws://127.0.0.1:8888/api/terminal/ws/...`` +and drive a shell (CWE-306). + +This module provides a pure-ASGI middleware that validates the ``Origin`` +header on every WebSocket handshake before the route handler runs. Handshakes +from disallowed origins are rejected with close code 4403 and never reach the +application, so no PTY/session is ever created. + +Non-browser clients (CLI tools, native apps) do not send an ``Origin`` header +and are allowed through: the CSWSH threat model is browser-only, and raw +socket access is governed by the localhost bind / ``AUTOFORGE_ALLOW_REMOTE``. +""" + +import logging +from collections.abc import Awaitable, Callable, MutableMapping +from typing import Any +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +# ASGI type aliases (kept loose to avoid a hard dependency on starlette.types) +Scope = MutableMapping[str, Any] +Message = MutableMapping[str, Any] +Receive = Callable[[], Awaitable[Message]] +Send = Callable[[Message], Awaitable[None]] + +# WebSocket close code used when rejecting a disallowed origin. +# 4000-4999 is the range reserved for application-specific codes. +WS_CLOSE_FORBIDDEN = 4403 + +_LOCALHOST_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"}) + + +def _hostname_from_origin(origin: str) -> str | None: + """Extract the lowercase hostname from an Origin header value.""" + try: + return urlparse(origin).hostname + except ValueError: + return None + + +def _hostname_from_host(host: str) -> str | None: + """Extract the lowercase hostname from a Host header value (strips port).""" + try: + # Prefix with // so urlparse treats the value as a netloc + # (handles bracketed IPv6 literals like [::1]:8888). + return urlparse(f"//{host}").hostname + except ValueError: + return None + + +def is_allowed_ws_origin(origin: str | None, allow_remote: bool, host: str | None = None) -> bool: + """Decide whether a WebSocket handshake with the given Origin is allowed. + + Rules: + + * No ``Origin`` header (non-browser client such as a CLI or native app): + allowed. CSWSH is a browser-only threat, and non-browser network access + is already gated by the localhost bind / AUTOFORGE_ALLOW_REMOTE opt-in. + * Origin hostname is a localhost variant (``localhost``, ``127.0.0.1``, + ``::1``): allowed regardless of scheme or port. This covers the Vite + dev server (5173) and the production server (8888). + * ``allow_remote`` is True: additionally allow origins whose hostname + matches the host the browser connected to (the ``Host`` header), so the + remotely-served UI keeps working while foreign pages are still rejected. + * Everything else: rejected. + + Args: + origin: Value of the Origin header, or None if absent. + allow_remote: Whether AUTOFORGE_ALLOW_REMOTE is enabled. + host: Value of the Host header (used only when allow_remote is True). + + Returns: + True if the handshake should proceed, False if it must be rejected. + """ + if not origin: + return True + + hostname = _hostname_from_origin(origin) + if not hostname: + return False + + if hostname in _LOCALHOST_HOSTNAMES: + return True + + if allow_remote and host: + host_name = _hostname_from_host(host) + return host_name is not None and hostname == host_name + + return False + + +class WebSocketOriginMiddleware: + """Pure-ASGI middleware that rejects WS handshakes from disallowed origins. + + Implemented as raw ASGI (not ``@app.middleware("http")``) because + Starlette's http middleware decorator is never invoked for scope type + ``websocket`` -- which is exactly the gap that left the WS endpoints + unprotected. HTTP and lifespan scopes pass through untouched. + """ + + def __init__(self, app: Any, allow_remote: bool = False) -> None: + self.app = app + self.allow_remote = allow_remote + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "websocket": + await self.app(scope, receive, send) + return + + origin: str | None = None + host: str | None = None + for name, value in scope.get("headers", []): + if name == b"origin": + origin = value.decode("latin-1") + elif name == b"host": + host = value.decode("latin-1") + + if not is_allowed_ws_origin(origin, self.allow_remote, host): + logger.warning( + "Rejected WebSocket handshake from disallowed origin %r (path=%s)", + origin, + scope.get("path"), + ) + # Refuse the handshake without ever accepting: consume the + # connect event, then close. The route handler never runs, + # so no terminal/chat session is created. + message = await receive() + if message["type"] == "websocket.connect": + await send({"type": "websocket.close", "code": WS_CLOSE_FORBIDDEN}) + return + + await self.app(scope, receive, send) diff --git a/start.py b/start.py index f6aee659..9a5007b0 100644 --- a/start.py +++ b/start.py @@ -13,6 +13,18 @@ import sys from pathlib import Path +# Fail fast on unsupported Python versions. Older interpreters surface as +# opaque Claude SDK errors (e.g. "Control request timeout: initialize"). +if sys.version_info < (3, 11): + sys.exit( + "ERROR: AutoForge requires Python 3.11 or newer " + f"(you are running Python {sys.version.split()[0]}). " + "Older versions cause opaque Claude SDK errors such as " + "'Control request timeout: initialize'. " + "Recreate your virtual environment with Python 3.11+ " + "(python3.11 -m venv venv && pip install -r requirements.txt)." + ) + from dotenv import load_dotenv from auth import is_auth_error, print_auth_error_help diff --git a/test_ws_security.py b/test_ws_security.py new file mode 100644 index 00000000..24f16c25 --- /dev/null +++ b/test_ws_security.py @@ -0,0 +1,120 @@ +""" +Unit tests for WebSocket Origin validation (CSWSH protection). + +Tests is_allowed_ws_origin() and WebSocketOriginMiddleware from +server/utils/ws_security.py. +""" + +import asyncio +import unittest + +from server.utils.ws_security import ( + WS_CLOSE_FORBIDDEN, + WebSocketOriginMiddleware, + is_allowed_ws_origin, +) + + +class TestIsAllowedWsOrigin(unittest.TestCase): + """Tests for is_allowed_ws_origin().""" + + def test_missing_origin_allowed(self): + """Non-browser clients send no Origin header and must be allowed.""" + assert is_allowed_ws_origin(None, False) is True + assert is_allowed_ws_origin("", False) is True + assert is_allowed_ws_origin(None, True) is True + + def test_localhost_origins_allowed(self): + """Localhost variants are allowed regardless of scheme or port.""" + assert is_allowed_ws_origin("http://localhost:5173", False) is True + assert is_allowed_ws_origin("http://localhost:8888", False) is True + assert is_allowed_ws_origin("http://127.0.0.1:8888", False) is True + assert is_allowed_ws_origin("https://127.0.0.1", False) is True + assert is_allowed_ws_origin("http://[::1]:8888", False) is True + + def test_foreign_origins_rejected(self): + """Non-localhost origins are rejected in the default (local) mode.""" + assert is_allowed_ws_origin("http://evil.com", False) is False + assert is_allowed_ws_origin("https://evil.com:8888", False) is False + assert is_allowed_ws_origin("http://localhost.evil.com", False) is False + assert is_allowed_ws_origin("http://192.168.1.50:8888", False) is False + + def test_garbage_origin_rejected(self): + """Origins without a parseable hostname are rejected.""" + assert is_allowed_ws_origin("null", False) is False + assert is_allowed_ws_origin("not a url", False) is False + + def test_remote_mode_host_match_allowed(self): + """In remote mode, origins matching the connected Host are allowed.""" + assert is_allowed_ws_origin("http://192.168.1.50:8888", True, host="192.168.1.50:8888") is True + assert is_allowed_ws_origin("http://myserver:8888", True, host="myserver:8888") is True + # Localhost still allowed too + assert is_allowed_ws_origin("http://localhost:5173", True, host="192.168.1.50:8888") is True + + def test_remote_mode_foreign_origin_rejected(self): + """In remote mode, origins not matching the Host are still rejected.""" + assert is_allowed_ws_origin("http://evil.com", True, host="192.168.1.50:8888") is False + assert is_allowed_ws_origin("http://evil.com", True, host=None) is False + + +class _FakeApp: + """Minimal downstream ASGI app that records whether it was called.""" + + def __init__(self): + self.called = False + + async def __call__(self, scope, receive, send): + self.called = True + + +def _run_ws_handshake(middleware, headers): + """Drive a websocket scope through the middleware; return sent messages.""" + sent = [] + + async def receive(): + return {"type": "websocket.connect"} + + async def send(message): + sent.append(message) + + scope = {"type": "websocket", "path": "/api/terminal/ws/proj/term", "headers": headers} + asyncio.run(middleware(scope, receive, send)) + return sent + + +class TestWebSocketOriginMiddleware(unittest.TestCase): + """Tests for the ASGI middleware behavior.""" + + def test_http_scope_passes_through(self): + """HTTP requests are never touched by this middleware.""" + app = _FakeApp() + middleware = WebSocketOriginMiddleware(app, allow_remote=False) + asyncio.run(middleware({"type": "http", "headers": []}, None, None)) + assert app.called is True + + def test_allowed_origin_reaches_app(self): + """Localhost-origin handshakes reach the route handler.""" + app = _FakeApp() + middleware = WebSocketOriginMiddleware(app, allow_remote=False) + sent = _run_ws_handshake(middleware, [(b"origin", b"http://localhost:8888")]) + assert app.called is True + assert sent == [] + + def test_missing_origin_reaches_app(self): + """Handshakes without an Origin header (non-browser) reach the app.""" + app = _FakeApp() + middleware = WebSocketOriginMiddleware(app, allow_remote=False) + _run_ws_handshake(middleware, []) + assert app.called is True + + def test_foreign_origin_rejected_before_app(self): + """Cross-site handshakes are closed with 4403 and never reach the app.""" + app = _FakeApp() + middleware = WebSocketOriginMiddleware(app, allow_remote=False) + sent = _run_ws_handshake(middleware, [(b"origin", b"http://evil.com")]) + assert app.called is False + assert sent == [{"type": "websocket.close", "code": WS_CLOSE_FORBIDDEN}] + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index 003dcd86..cd891b9b 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -62,6 +62,8 @@ interface WebSocketState { const MAX_LOGS = 100 // Keep last 100 log lines const MAX_ACTIVITY = 20 // Keep last 20 activity items const MAX_AGENT_LOGS = 500 // Keep last 500 log lines per agent +const HEARTBEAT_INTERVAL_MS = 20_000 // App-level ping cadence (well inside common 60s proxy idle timeouts) +const STALE_TIMEOUT_MS = 90_000 // No inbound traffic for this long => treat connection as dead export function useProjectWebSocket(projectName: string | null) { const [state, setState] = useState({ @@ -85,25 +87,50 @@ export function useProjectWebSocket(projectName: string | null) { const wsRef = useRef(null) const reconnectTimeoutRef = useRef(null) const reconnectAttempts = useRef(0) + const lastMessageAtRef = useRef(Date.now()) const connect = useCallback(() => { if (!projectName) return + // Cancel any pending reconnect so we never stack duplicate attempts + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + + // Guard against duplicate sockets + if ( + wsRef.current && + (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING) + ) { + return + } + // Build WebSocket URL const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const host = window.location.host const wsUrl = `${protocol}//${host}/ws/projects/${encodeURIComponent(projectName)}` try { + // Detach any old socket so its onclose can't schedule a stale reconnect + if (wsRef.current) { + wsRef.current.onclose = null + wsRef.current.onerror = null + wsRef.current.onmessage = null + wsRef.current.close() + } + const ws = new WebSocket(wsUrl) wsRef.current = ws ws.onopen = () => { + lastMessageAtRef.current = Date.now() setState(prev => ({ ...prev, isConnected: true })) reconnectAttempts.current = 0 } ws.onmessage = (event) => { + lastMessageAtRef.current = Date.now() try { const message: WSMessage = JSON.parse(event.data) @@ -385,11 +412,19 @@ export function useProjectWebSocket(projectName: string | null) { } }, [projectName]) - // Send ping to keep connection alive - const sendPing = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ type: 'ping' })) + // Heartbeat: detect dead connections and keep live ones alive. + // Any inbound message (including the server's pong) refreshes lastMessageAtRef. + const heartbeat = useCallback(() => { + if (wsRef.current?.readyState !== WebSocket.OPEN) return + + if (Date.now() - lastMessageAtRef.current > STALE_TIMEOUT_MS) { + // Half-open socket (sleep/wake, network change, proxy idle drop): + // send() won't throw, so force-close to trigger the onclose reconnect path + wsRef.current.close() + return } + + wsRef.current.send(JSON.stringify({ type: 'ping' })) }, []) // Clear celebration and show next one from queue if available @@ -430,6 +465,9 @@ export function useProjectWebSocket(projectName: string | null) { if (!projectName) { // Disconnect if no project if (wsRef.current) { + wsRef.current.onclose = null + wsRef.current.onerror = null + wsRef.current.onmessage = null wsRef.current.close() wsRef.current = null } @@ -438,20 +476,49 @@ export function useProjectWebSocket(projectName: string | null) { connect() - // Ping every 30 seconds - const pingInterval = setInterval(sendPing, 30000) + // App-level heartbeat (protects against proxy idle timeouts and detects dead sockets) + const pingInterval = setInterval(heartbeat, HEARTBEAT_INTERVAL_MS) + + // Reconnect immediately when the tab becomes visible or the network comes back, + // instead of waiting for a background-throttled reconnect timer + const wakeUp = () => { + if (document.visibilityState === 'hidden') return + const ws = wsRef.current + if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + reconnectAttempts.current = 0 + connect() + } else if (ws.readyState === WebSocket.OPEN && Date.now() - lastMessageAtRef.current > STALE_TIMEOUT_MS) { + ws.close() // dead after sleep/wake — onclose reconnects immediately + } else if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) // probe liveness right away + } + } + document.addEventListener('visibilitychange', wakeUp) + window.addEventListener('online', wakeUp) return () => { clearInterval(pingInterval) + document.removeEventListener('visibilitychange', wakeUp) + window.removeEventListener('online', wakeUp) if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null } + reconnectAttempts.current = 0 if (wsRef.current) { + // Detach handlers so this intentional close can't schedule a stale reconnect + wsRef.current.onclose = null + wsRef.current.onerror = null + wsRef.current.onmessage = null wsRef.current.close() wsRef.current = null } } - }, [projectName, connect, sendPing]) + }, [projectName, connect, heartbeat]) // Defense-in-depth: cleanup stale agents for users who leave UI open for hours // This catches edge cases where completion messages are missed