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.
Files changed (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. 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
+ ]