gobby 0.2.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket server for real-time bidirectional communication.
|
|
3
|
+
|
|
4
|
+
Provides tool call proxying, session broadcasting, and connection management
|
|
5
|
+
with optional authentication and ping/pong keepalive.
|
|
6
|
+
|
|
7
|
+
Local-first version: Authentication is optional (defaults to always-allow).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from collections.abc import Callable, Coroutine
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from websockets.asyncio.server import serve
|
|
20
|
+
from websockets.datastructures import Headers
|
|
21
|
+
from websockets.exceptions import ConnectionClosed, ConnectionClosedError
|
|
22
|
+
from websockets.http11 import Response
|
|
23
|
+
|
|
24
|
+
from gobby.mcp_proxy.manager import MCPClientManager
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Protocol for WebSocket connection to include custom attributes
|
|
30
|
+
class WebSocketClient(Protocol):
|
|
31
|
+
user_id: str
|
|
32
|
+
subscriptions: set[str]
|
|
33
|
+
latency: float
|
|
34
|
+
remote_address: Any
|
|
35
|
+
|
|
36
|
+
async def send(self, message: str) -> None: ...
|
|
37
|
+
async def close(self, code: int = 1000, reason: str = "") -> None: ...
|
|
38
|
+
async def wait_closed(self) -> None: ...
|
|
39
|
+
def __aiter__(self) -> Any: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class WebSocketConfig:
|
|
44
|
+
"""Configuration for WebSocket server."""
|
|
45
|
+
|
|
46
|
+
host: str = "localhost"
|
|
47
|
+
port: int = 8765
|
|
48
|
+
ping_interval: int = 30 # seconds
|
|
49
|
+
ping_timeout: int = 10 # seconds
|
|
50
|
+
max_message_size: int = 2 * 1024 * 1024 # 2MB
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class WebSocketServer:
|
|
54
|
+
"""
|
|
55
|
+
WebSocket server for real-time communication.
|
|
56
|
+
|
|
57
|
+
Provides:
|
|
58
|
+
- Optional Bearer token authentication via handshake headers
|
|
59
|
+
- JSON-RPC style message protocol
|
|
60
|
+
- Tool call routing to MCP servers
|
|
61
|
+
- Session update broadcasting
|
|
62
|
+
- Automatic ping/pong keepalive
|
|
63
|
+
- Connection tracking and cleanup
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
```python
|
|
67
|
+
config = WebSocketConfig(host="0.0.0.0", port=8765)
|
|
68
|
+
|
|
69
|
+
async with WebSocketServer(config, mcp_manager) as server:
|
|
70
|
+
await server.serve_forever()
|
|
71
|
+
```
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
config: WebSocketConfig,
|
|
77
|
+
mcp_manager: MCPClientManager,
|
|
78
|
+
auth_callback: Callable[[str], Coroutine[Any, Any, str | None]] | None = None,
|
|
79
|
+
stop_registry: Any = None,
|
|
80
|
+
):
|
|
81
|
+
"""
|
|
82
|
+
Initialize WebSocket server.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
config: WebSocket server configuration
|
|
86
|
+
mcp_manager: MCP client manager for tool routing
|
|
87
|
+
auth_callback: Optional async function that validates token and returns user_id.
|
|
88
|
+
If None, all connections are accepted (local-first mode).
|
|
89
|
+
stop_registry: Optional StopRegistry for handling stop requests from clients.
|
|
90
|
+
"""
|
|
91
|
+
self.config = config
|
|
92
|
+
self.mcp_manager = mcp_manager
|
|
93
|
+
self.auth_callback = auth_callback
|
|
94
|
+
self.stop_registry = stop_registry
|
|
95
|
+
|
|
96
|
+
# Connected clients: {websocket: client_metadata}
|
|
97
|
+
self.clients: dict[Any, dict[str, Any]] = {}
|
|
98
|
+
|
|
99
|
+
# Server instance (set when started)
|
|
100
|
+
self._server: Any = None
|
|
101
|
+
self._serve_task: asyncio.Task[None] | None = None
|
|
102
|
+
|
|
103
|
+
async def __aenter__(self) -> "WebSocketServer":
|
|
104
|
+
"""Async context manager entry."""
|
|
105
|
+
await self.start()
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
109
|
+
"""Async context manager exit."""
|
|
110
|
+
await self.stop()
|
|
111
|
+
|
|
112
|
+
async def _authenticate(self, websocket: Any, request: Any) -> Response | None:
|
|
113
|
+
"""
|
|
114
|
+
Authenticate WebSocket connection via Bearer token.
|
|
115
|
+
|
|
116
|
+
In local-first mode (no auth_callback), all connections are accepted
|
|
117
|
+
with a generated local user ID.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
websocket: WebSocket connection
|
|
121
|
+
request: HTTP request with headers
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
None to accept connection, Response to reject
|
|
125
|
+
"""
|
|
126
|
+
# Local-first mode: accept all connections
|
|
127
|
+
if self.auth_callback is None:
|
|
128
|
+
websocket.user_id = f"local-{uuid4().hex[:8]}"
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
# Auth callback provided - require Bearer token
|
|
132
|
+
auth_header = request.headers.get("Authorization")
|
|
133
|
+
|
|
134
|
+
if not auth_header:
|
|
135
|
+
logger.warning(
|
|
136
|
+
f"Connection rejected: Missing Authorization header from {websocket.remote_address}"
|
|
137
|
+
)
|
|
138
|
+
return Response(401, "Unauthorized: Missing Authorization header\n", Headers())
|
|
139
|
+
|
|
140
|
+
if not auth_header.startswith("Bearer "):
|
|
141
|
+
logger.warning(
|
|
142
|
+
f"Connection rejected: Invalid Authorization format from {websocket.remote_address}"
|
|
143
|
+
)
|
|
144
|
+
return Response(401, "Unauthorized: Expected Bearer token\n", Headers())
|
|
145
|
+
|
|
146
|
+
token = auth_header.removeprefix("Bearer ")
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
user_id = await self.auth_callback(token)
|
|
150
|
+
|
|
151
|
+
if not user_id:
|
|
152
|
+
logger.warning(
|
|
153
|
+
f"Connection rejected: Invalid token from {websocket.remote_address}"
|
|
154
|
+
)
|
|
155
|
+
return Response(403, "Forbidden: Invalid token\n", Headers())
|
|
156
|
+
|
|
157
|
+
# Store user_id on websocket for handler
|
|
158
|
+
websocket.user_id = user_id
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.error(f"Authentication error from {websocket.remote_address}: {e}")
|
|
163
|
+
return Response(500, "Internal server error\n", Headers())
|
|
164
|
+
|
|
165
|
+
async def _handle_connection(self, websocket: Any) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Handle WebSocket connection lifecycle.
|
|
168
|
+
|
|
169
|
+
Registers client, processes messages, and ensures cleanup
|
|
170
|
+
on disconnect. Always cleans up client state even on error.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
websocket: Connected WebSocket client
|
|
174
|
+
"""
|
|
175
|
+
user_id = websocket.user_id
|
|
176
|
+
client_id = str(uuid4())
|
|
177
|
+
|
|
178
|
+
# Register client
|
|
179
|
+
self.clients[websocket] = {
|
|
180
|
+
"id": client_id,
|
|
181
|
+
"user_id": user_id,
|
|
182
|
+
"connected_at": datetime.now(UTC),
|
|
183
|
+
"remote_address": websocket.remote_address,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
logger.debug(
|
|
187
|
+
f"Client {user_id} ({client_id}) connected from {websocket.remote_address}. "
|
|
188
|
+
f"Total clients: {len(self.clients)}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
# Send welcome message
|
|
193
|
+
await websocket.send(
|
|
194
|
+
json.dumps(
|
|
195
|
+
{
|
|
196
|
+
"type": "connection_established",
|
|
197
|
+
"client_id": client_id,
|
|
198
|
+
"user_id": user_id,
|
|
199
|
+
"latency": websocket.latency,
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Message processing loop
|
|
205
|
+
async for message in websocket:
|
|
206
|
+
try:
|
|
207
|
+
await self._handle_message(websocket, message)
|
|
208
|
+
except json.JSONDecodeError:
|
|
209
|
+
await self._send_error(websocket, "Invalid JSON format")
|
|
210
|
+
except Exception:
|
|
211
|
+
logger.exception(f"Message handling error for client {client_id}")
|
|
212
|
+
await self._send_error(websocket, "Internal server error")
|
|
213
|
+
|
|
214
|
+
except ConnectionClosedError as e:
|
|
215
|
+
logger.warning(f"Client {client_id} connection error: {e}")
|
|
216
|
+
|
|
217
|
+
except ConnectionClosed:
|
|
218
|
+
logger.debug(f"Client {client_id} disconnected normally")
|
|
219
|
+
|
|
220
|
+
except Exception:
|
|
221
|
+
logger.exception(f"Unexpected error for client {client_id}")
|
|
222
|
+
|
|
223
|
+
finally:
|
|
224
|
+
# Always cleanup client state
|
|
225
|
+
self.clients.pop(websocket, None)
|
|
226
|
+
logger.debug(f"Client {client_id} cleaned up. Remaining clients: {len(self.clients)}")
|
|
227
|
+
|
|
228
|
+
async def _handle_message(self, websocket: Any, message: str) -> None:
|
|
229
|
+
"""
|
|
230
|
+
Route incoming message to appropriate handler.
|
|
231
|
+
|
|
232
|
+
Supports message types:
|
|
233
|
+
- tool_call: Route to MCP server
|
|
234
|
+
- ping: Manual latency check
|
|
235
|
+
- Other types: Log warning
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
websocket: Sender's WebSocket connection
|
|
239
|
+
message: JSON string message
|
|
240
|
+
"""
|
|
241
|
+
data = json.loads(message)
|
|
242
|
+
msg_type = data.get("type")
|
|
243
|
+
|
|
244
|
+
if msg_type == "tool_call":
|
|
245
|
+
await self._handle_tool_call(websocket, data)
|
|
246
|
+
|
|
247
|
+
elif msg_type == "ping":
|
|
248
|
+
await self._handle_ping(websocket, data)
|
|
249
|
+
|
|
250
|
+
elif msg_type == "subscribe":
|
|
251
|
+
await self._handle_subscribe(websocket, data)
|
|
252
|
+
|
|
253
|
+
elif msg_type == "unsubscribe":
|
|
254
|
+
await self._handle_unsubscribe(websocket, data)
|
|
255
|
+
|
|
256
|
+
elif msg_type == "stop_request":
|
|
257
|
+
await self._handle_stop_request(websocket, data)
|
|
258
|
+
|
|
259
|
+
else:
|
|
260
|
+
logger.warning(f"Unknown message type: {msg_type}")
|
|
261
|
+
await self._send_error(websocket, f"Unknown message type: {msg_type}")
|
|
262
|
+
|
|
263
|
+
async def _handle_tool_call(self, websocket: Any, data: dict[str, Any]) -> None:
|
|
264
|
+
"""
|
|
265
|
+
Handle tool_call message and route to MCP server.
|
|
266
|
+
|
|
267
|
+
Message format:
|
|
268
|
+
{
|
|
269
|
+
"type": "tool_call",
|
|
270
|
+
"request_id": "uuid",
|
|
271
|
+
"mcp": "memory",
|
|
272
|
+
"tool": "add_messages",
|
|
273
|
+
"args": {...}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
Response format:
|
|
277
|
+
{
|
|
278
|
+
"type": "tool_result",
|
|
279
|
+
"request_id": "uuid",
|
|
280
|
+
"result": {...}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
websocket: Client WebSocket connection
|
|
285
|
+
data: Parsed tool call message
|
|
286
|
+
"""
|
|
287
|
+
request_id = data.get("request_id")
|
|
288
|
+
mcp_name = data.get("mcp")
|
|
289
|
+
tool_name = data.get("tool")
|
|
290
|
+
args = data.get("args", {})
|
|
291
|
+
|
|
292
|
+
if (
|
|
293
|
+
not isinstance(request_id, str)
|
|
294
|
+
or not isinstance(mcp_name, str)
|
|
295
|
+
or not isinstance(tool_name, str)
|
|
296
|
+
):
|
|
297
|
+
await self._send_error(
|
|
298
|
+
websocket,
|
|
299
|
+
"Missing or invalid required fields: request_id, mcp, tool (must be strings)",
|
|
300
|
+
request_id=str(request_id) if request_id else None,
|
|
301
|
+
)
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
# Route to MCP via manager
|
|
306
|
+
result = await self.mcp_manager.call_tool(mcp_name, tool_name, args)
|
|
307
|
+
|
|
308
|
+
# Send result back to client
|
|
309
|
+
await websocket.send(
|
|
310
|
+
json.dumps(
|
|
311
|
+
{
|
|
312
|
+
"type": "tool_result",
|
|
313
|
+
"request_id": request_id,
|
|
314
|
+
"result": result,
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
except ValueError as e:
|
|
320
|
+
# Unknown MCP server
|
|
321
|
+
await self._send_error(websocket, str(e), request_id=request_id)
|
|
322
|
+
|
|
323
|
+
except Exception as e:
|
|
324
|
+
logger.exception(f"Tool call error: {mcp_name}.{tool_name}")
|
|
325
|
+
await self._send_error(websocket, f"Tool call failed: {str(e)}", request_id=request_id)
|
|
326
|
+
|
|
327
|
+
async def _handle_ping(self, websocket: Any, data: dict[str, Any]) -> None:
|
|
328
|
+
"""
|
|
329
|
+
Handle manual ping message for latency measurement.
|
|
330
|
+
|
|
331
|
+
Sends pong response with latency value.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
websocket: Client WebSocket connection
|
|
335
|
+
data: Ping message (ignored)
|
|
336
|
+
"""
|
|
337
|
+
await websocket.send(
|
|
338
|
+
json.dumps(
|
|
339
|
+
{
|
|
340
|
+
"type": "pong",
|
|
341
|
+
"latency": websocket.latency,
|
|
342
|
+
}
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
async def _send_error(
|
|
347
|
+
self,
|
|
348
|
+
websocket: Any,
|
|
349
|
+
message: str,
|
|
350
|
+
request_id: str | None = None,
|
|
351
|
+
code: str = "ERROR",
|
|
352
|
+
) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Send error message to client.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
websocket: Client WebSocket connection
|
|
358
|
+
message: Error message
|
|
359
|
+
request_id: Optional request ID for correlation
|
|
360
|
+
code: Error code (default: "ERROR")
|
|
361
|
+
"""
|
|
362
|
+
error_msg = {
|
|
363
|
+
"type": "error",
|
|
364
|
+
"code": code,
|
|
365
|
+
"message": message,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if request_id:
|
|
369
|
+
error_msg["request_id"] = request_id
|
|
370
|
+
|
|
371
|
+
await websocket.send(json.dumps(error_msg))
|
|
372
|
+
|
|
373
|
+
async def _handle_subscribe(self, websocket: Any, data: dict[str, Any]) -> None:
|
|
374
|
+
"""
|
|
375
|
+
Handle subscribe message to register for specific events.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
websocket: Client WebSocket connection
|
|
379
|
+
data: Subscribe message with "events" list
|
|
380
|
+
"""
|
|
381
|
+
events = data.get("events", [])
|
|
382
|
+
if not isinstance(events, list):
|
|
383
|
+
await self._send_error(websocket, "events must be a list of strings")
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
if not hasattr(websocket, "subscriptions"):
|
|
387
|
+
websocket.subscriptions = set()
|
|
388
|
+
|
|
389
|
+
websocket.subscriptions.update(events)
|
|
390
|
+
logger.debug(f"Client {websocket.user_id} subscribed to: {events}")
|
|
391
|
+
|
|
392
|
+
await websocket.send(
|
|
393
|
+
json.dumps(
|
|
394
|
+
{
|
|
395
|
+
"type": "subscribe_success",
|
|
396
|
+
"events": list(websocket.subscriptions),
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
async def _handle_unsubscribe(self, websocket: Any, data: dict[str, Any]) -> None:
|
|
402
|
+
"""
|
|
403
|
+
Handle unsubscribe message to unregister from specific events.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
websocket: Client WebSocket connection
|
|
407
|
+
data: Unsubscribe message with "events" list
|
|
408
|
+
"""
|
|
409
|
+
events = data.get("events", [])
|
|
410
|
+
if not isinstance(events, list):
|
|
411
|
+
await self._send_error(websocket, "events must be a list of strings")
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
current_subscriptions: set[str] = getattr(websocket, "subscriptions", set())
|
|
415
|
+
|
|
416
|
+
# If events list is empty or contains "*", unsubscribe from all
|
|
417
|
+
if not events or "*" in events:
|
|
418
|
+
current_subscriptions.clear()
|
|
419
|
+
else:
|
|
420
|
+
for event in events:
|
|
421
|
+
current_subscriptions.discard(event)
|
|
422
|
+
|
|
423
|
+
logger.debug(f"Client {websocket.user_id} unsubscribed from: {events}")
|
|
424
|
+
|
|
425
|
+
await websocket.send(
|
|
426
|
+
json.dumps(
|
|
427
|
+
{
|
|
428
|
+
"type": "unsubscribe_success",
|
|
429
|
+
"events": list(current_subscriptions),
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
async def _handle_stop_request(self, websocket: Any, data: dict[str, Any]) -> None:
|
|
435
|
+
"""
|
|
436
|
+
Handle stop_request message to signal a session to stop.
|
|
437
|
+
|
|
438
|
+
Message format:
|
|
439
|
+
{
|
|
440
|
+
"type": "stop_request",
|
|
441
|
+
"session_id": "uuid",
|
|
442
|
+
"reason": "optional reason string"
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
Response format:
|
|
446
|
+
{
|
|
447
|
+
"type": "stop_response",
|
|
448
|
+
"session_id": "uuid",
|
|
449
|
+
"success": true,
|
|
450
|
+
"signal_id": "uuid"
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
websocket: Client WebSocket connection
|
|
455
|
+
data: Parsed stop request message
|
|
456
|
+
"""
|
|
457
|
+
session_id = data.get("session_id")
|
|
458
|
+
reason = data.get("reason", "WebSocket stop request")
|
|
459
|
+
|
|
460
|
+
if not session_id:
|
|
461
|
+
await self._send_error(websocket, "Missing required field: session_id")
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
if not self.stop_registry:
|
|
465
|
+
await self._send_error(websocket, "Stop registry not available", code="UNAVAILABLE")
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
# Signal the stop
|
|
470
|
+
signal = self.stop_registry.signal_stop(
|
|
471
|
+
session_id=session_id,
|
|
472
|
+
reason=reason,
|
|
473
|
+
source="websocket",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Send acknowledgment
|
|
477
|
+
await websocket.send(
|
|
478
|
+
json.dumps(
|
|
479
|
+
{
|
|
480
|
+
"type": "stop_response",
|
|
481
|
+
"session_id": session_id,
|
|
482
|
+
"success": True,
|
|
483
|
+
"signal_id": signal.session_id,
|
|
484
|
+
"signaled_at": signal.requested_at.isoformat(),
|
|
485
|
+
}
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Broadcast the stop_requested event to all clients
|
|
490
|
+
await self.broadcast_autonomous_event(
|
|
491
|
+
event="stop_requested",
|
|
492
|
+
session_id=session_id,
|
|
493
|
+
reason=reason,
|
|
494
|
+
source="websocket",
|
|
495
|
+
signal_id=signal.session_id,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
logger.info(f"Stop requested for session {session_id} via WebSocket")
|
|
499
|
+
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.error(f"Error handling stop request: {e}")
|
|
502
|
+
await self._send_error(websocket, f"Failed to signal stop: {str(e)}")
|
|
503
|
+
|
|
504
|
+
async def broadcast(self, message: dict[str, Any]) -> None:
|
|
505
|
+
"""
|
|
506
|
+
Broadcast message to all connected clients.
|
|
507
|
+
|
|
508
|
+
Filters messages based on client subscriptions:
|
|
509
|
+
1. If message type is NOT 'hook_event', always send (system messages)
|
|
510
|
+
2. If message type IS 'hook_event':
|
|
511
|
+
- If client has NO subscriptions, send ALL events (default behavior)
|
|
512
|
+
- If client HAS subscriptions, only send if event_type in subscriptions
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
message: Dictionary to serialize and send
|
|
516
|
+
"""
|
|
517
|
+
if not self.clients:
|
|
518
|
+
return # No clients connected, silently skip
|
|
519
|
+
|
|
520
|
+
message_str = json.dumps(message)
|
|
521
|
+
sent_count = 0
|
|
522
|
+
failed_count = 0
|
|
523
|
+
|
|
524
|
+
# Pre-calculate filtering criteria
|
|
525
|
+
is_hook_event = message.get("type") == "hook_event"
|
|
526
|
+
event_type = message.get("event_type")
|
|
527
|
+
|
|
528
|
+
for websocket in list(self.clients.keys()):
|
|
529
|
+
try:
|
|
530
|
+
# Filter logic
|
|
531
|
+
if is_hook_event:
|
|
532
|
+
# If subscriptions are present, we MUST match.
|
|
533
|
+
# If NO subscriptions present, we default to sending everything (backward compatibility)
|
|
534
|
+
subs = getattr(websocket, "subscriptions", None)
|
|
535
|
+
if subs is not None:
|
|
536
|
+
# Filtering active
|
|
537
|
+
if event_type not in subs and "*" not in subs:
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
# Session Message Logic
|
|
541
|
+
elif message.get("type") == "session_message":
|
|
542
|
+
# Only send to clients subscribed to "session_message" or "*"
|
|
543
|
+
# If NO subscriptions present (None), we invoke backward compat logic?
|
|
544
|
+
# Actually for new feature session_message, let's say:
|
|
545
|
+
# If subscriptions is None => Receive All (simple tools)
|
|
546
|
+
# If subscriptions is set => Must include "session_message" or "*"
|
|
547
|
+
|
|
548
|
+
subs = getattr(websocket, "subscriptions", None)
|
|
549
|
+
if subs is not None:
|
|
550
|
+
if "session_message" not in subs and "*" not in subs:
|
|
551
|
+
continue
|
|
552
|
+
|
|
553
|
+
await websocket.send(message_str)
|
|
554
|
+
sent_count += 1
|
|
555
|
+
except ConnectionClosed:
|
|
556
|
+
# Client disconnecting, will be cleaned up in handler
|
|
557
|
+
failed_count += 1
|
|
558
|
+
except Exception as e:
|
|
559
|
+
logger.warning(f"Broadcast failed for client: {e}")
|
|
560
|
+
failed_count += 1
|
|
561
|
+
|
|
562
|
+
logger.debug(f"Broadcast complete: {sent_count} sent, {failed_count} failed")
|
|
563
|
+
|
|
564
|
+
async def broadcast_session_update(self, event: str, **kwargs: Any) -> None:
|
|
565
|
+
"""
|
|
566
|
+
Broadcast session update to all clients.
|
|
567
|
+
|
|
568
|
+
Convenience method for sending session_update messages.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
event: Event type (e.g., "token_refreshed", "logout")
|
|
572
|
+
**kwargs: Additional event data
|
|
573
|
+
"""
|
|
574
|
+
message = {
|
|
575
|
+
"type": "session_update",
|
|
576
|
+
"event": event,
|
|
577
|
+
**kwargs,
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
await self.broadcast(message)
|
|
581
|
+
|
|
582
|
+
async def broadcast_agent_event(
|
|
583
|
+
self,
|
|
584
|
+
event: str,
|
|
585
|
+
run_id: str,
|
|
586
|
+
parent_session_id: str,
|
|
587
|
+
**kwargs: Any,
|
|
588
|
+
) -> None:
|
|
589
|
+
"""
|
|
590
|
+
Broadcast agent event to all clients.
|
|
591
|
+
|
|
592
|
+
Used for agent lifecycle events like started, completed, cancelled.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
event: Event type (agent_started, agent_completed, agent_failed, agent_cancelled)
|
|
596
|
+
run_id: Agent run ID
|
|
597
|
+
parent_session_id: Parent session that spawned the agent
|
|
598
|
+
**kwargs: Additional event data (provider, status, etc.)
|
|
599
|
+
"""
|
|
600
|
+
message = {
|
|
601
|
+
"type": "agent_event",
|
|
602
|
+
"event": event,
|
|
603
|
+
"run_id": run_id,
|
|
604
|
+
"parent_session_id": parent_session_id,
|
|
605
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
606
|
+
**kwargs,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
await self.broadcast(message)
|
|
610
|
+
|
|
611
|
+
async def broadcast_worktree_event(
|
|
612
|
+
self,
|
|
613
|
+
event: str,
|
|
614
|
+
worktree_id: str,
|
|
615
|
+
**kwargs: Any,
|
|
616
|
+
) -> None:
|
|
617
|
+
"""
|
|
618
|
+
Broadcast worktree event to all clients.
|
|
619
|
+
|
|
620
|
+
Used for worktree lifecycle events like created, claimed, released, merged.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
event: Event type (worktree_created, worktree_claimed, worktree_released, worktree_merged)
|
|
624
|
+
worktree_id: Worktree ID
|
|
625
|
+
**kwargs: Additional event data (branch_name, task_id, session_id, etc.)
|
|
626
|
+
"""
|
|
627
|
+
message = {
|
|
628
|
+
"type": "worktree_event",
|
|
629
|
+
"event": event,
|
|
630
|
+
"worktree_id": worktree_id,
|
|
631
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
632
|
+
**kwargs,
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
await self.broadcast(message)
|
|
636
|
+
|
|
637
|
+
async def broadcast_autonomous_event(
|
|
638
|
+
self,
|
|
639
|
+
event: str,
|
|
640
|
+
session_id: str,
|
|
641
|
+
**kwargs: Any,
|
|
642
|
+
) -> None:
|
|
643
|
+
"""
|
|
644
|
+
Broadcast autonomous execution event to all clients.
|
|
645
|
+
|
|
646
|
+
Used for autonomous loop lifecycle and progress events:
|
|
647
|
+
- task_started: A task was selected for work
|
|
648
|
+
- task_completed: A task was completed
|
|
649
|
+
- validation_failed: Task validation failed
|
|
650
|
+
- stuck_detected: Loop detected stuck condition
|
|
651
|
+
- stop_requested: External stop signal received
|
|
652
|
+
- progress_recorded: Progress event recorded
|
|
653
|
+
- loop_started: Autonomous loop started
|
|
654
|
+
- loop_stopped: Autonomous loop stopped
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
event: Event type
|
|
658
|
+
session_id: Session ID of the autonomous loop
|
|
659
|
+
**kwargs: Additional event data (task_id, reason, details, etc.)
|
|
660
|
+
"""
|
|
661
|
+
message = {
|
|
662
|
+
"type": "autonomous_event",
|
|
663
|
+
"event": event,
|
|
664
|
+
"session_id": session_id,
|
|
665
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
666
|
+
**kwargs,
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
await self.broadcast(message)
|
|
670
|
+
|
|
671
|
+
async def start(self) -> None:
|
|
672
|
+
"""
|
|
673
|
+
Start WebSocket server.
|
|
674
|
+
|
|
675
|
+
Creates server instance and begins accepting connections.
|
|
676
|
+
Does not block - use serve_forever() or context manager.
|
|
677
|
+
"""
|
|
678
|
+
if self._server is not None:
|
|
679
|
+
logger.warning("WebSocket server already started")
|
|
680
|
+
return
|
|
681
|
+
|
|
682
|
+
self._server = await serve(
|
|
683
|
+
self._handle_connection,
|
|
684
|
+
host=self.config.host,
|
|
685
|
+
port=self.config.port,
|
|
686
|
+
process_request=self._authenticate,
|
|
687
|
+
ping_interval=self.config.ping_interval,
|
|
688
|
+
ping_timeout=self.config.ping_timeout,
|
|
689
|
+
max_size=self.config.max_message_size,
|
|
690
|
+
compression="deflate",
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
logger.debug(f"WebSocket server started on ws://{self.config.host}:{self.config.port}")
|
|
694
|
+
|
|
695
|
+
async def stop(self) -> None:
|
|
696
|
+
"""
|
|
697
|
+
Stop WebSocket server and close all connections.
|
|
698
|
+
|
|
699
|
+
Gracefully closes all client connections and shuts down server.
|
|
700
|
+
"""
|
|
701
|
+
if self._server is None:
|
|
702
|
+
logger.warning("WebSocket server not started")
|
|
703
|
+
return
|
|
704
|
+
|
|
705
|
+
logger.debug("Stopping WebSocket server...")
|
|
706
|
+
|
|
707
|
+
# Close server (stops accepting new connections)
|
|
708
|
+
self._server.close()
|
|
709
|
+
await self._server.wait_closed()
|
|
710
|
+
|
|
711
|
+
# Close remaining client connections with timeout
|
|
712
|
+
for websocket in list(self.clients.keys()):
|
|
713
|
+
try:
|
|
714
|
+
await asyncio.wait_for(
|
|
715
|
+
websocket.close(code=1001, reason="Server shutting down"), timeout=2.0
|
|
716
|
+
)
|
|
717
|
+
except TimeoutError:
|
|
718
|
+
logger.warning("Client connection close timed out")
|
|
719
|
+
except Exception as e:
|
|
720
|
+
logger.warning(f"Error closing client connection: {e}")
|
|
721
|
+
|
|
722
|
+
self._server = None
|
|
723
|
+
logger.debug("WebSocket server stopped")
|
|
724
|
+
|
|
725
|
+
async def serve_forever(self) -> None:
|
|
726
|
+
"""
|
|
727
|
+
Run server until cancelled.
|
|
728
|
+
|
|
729
|
+
Blocks forever until interrupted (Ctrl+C) or task cancelled.
|
|
730
|
+
Use in main() for standalone server operation.
|
|
731
|
+
"""
|
|
732
|
+
if self._server is None:
|
|
733
|
+
raise RuntimeError("Server not started. Call start() first.")
|
|
734
|
+
|
|
735
|
+
try:
|
|
736
|
+
await asyncio.Future() # Run forever
|
|
737
|
+
except asyncio.CancelledError:
|
|
738
|
+
logger.debug("Server cancelled, shutting down...")
|
|
739
|
+
await self.stop()
|
|
740
|
+
raise
|
|
741
|
+
|
|
742
|
+
def get_client_count(self) -> int:
|
|
743
|
+
"""
|
|
744
|
+
Get number of connected clients.
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
Count of active client connections
|
|
748
|
+
"""
|
|
749
|
+
return len(self.clients)
|
|
750
|
+
|
|
751
|
+
def get_clients_info(self) -> list[dict[str, Any]]:
|
|
752
|
+
"""
|
|
753
|
+
Get information about all connected clients.
|
|
754
|
+
|
|
755
|
+
Returns:
|
|
756
|
+
List of client metadata dictionaries
|
|
757
|
+
"""
|
|
758
|
+
return [
|
|
759
|
+
{
|
|
760
|
+
"id": metadata["id"],
|
|
761
|
+
"user_id": metadata["user_id"],
|
|
762
|
+
"connected_at": metadata["connected_at"].isoformat(),
|
|
763
|
+
"remote_address": str(metadata["remote_address"]),
|
|
764
|
+
}
|
|
765
|
+
for metadata in self.clients.values()
|
|
766
|
+
]
|