@agentunion/kite 1.3.2 → 1.5.0

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 (293) hide show
  1. package/CHANGELOG.md +302 -0
  2. package/cli.js +119 -4
  3. package/core/dependency_checker.py +250 -0
  4. package/core/env_checker.py +490 -0
  5. package/dependencies_lock.json +128 -0
  6. package/extensions/agents/assistant/entry.py +111 -1
  7. package/extensions/agents/assistant/server.py +279 -215
  8. package/extensions/channels/acp_channel/entry.py +111 -1
  9. package/extensions/channels/acp_channel/module.md +23 -22
  10. package/extensions/channels/acp_channel/server.py +279 -215
  11. package/extensions/event_hub_bench/entry.py +107 -1
  12. package/extensions/services/backup/entry.py +306 -21
  13. package/extensions/services/backup/module.md +24 -22
  14. package/extensions/services/evol/auth_manager.py +443 -0
  15. package/extensions/services/evol/config.yaml +149 -0
  16. package/extensions/services/evol/config_loader.py +117 -0
  17. package/extensions/services/evol/entry.py +406 -0
  18. package/extensions/services/evol/evol_api.py +173 -0
  19. package/extensions/services/evol/evol_config.json5 +29 -0
  20. package/extensions/services/evol/migrate_tokens.py +122 -0
  21. package/extensions/services/evol/module.md +32 -0
  22. package/extensions/services/evol/pairing.py +250 -0
  23. package/extensions/services/evol/pairing_codes.jsonl +1 -0
  24. package/extensions/services/evol/relay.py +682 -0
  25. package/extensions/services/evol/relay_config.json5 +67 -0
  26. package/extensions/services/evol/routes/__init__.py +1 -0
  27. package/extensions/services/evol/routes/routes_management_ws.py +127 -0
  28. package/extensions/services/evol/routes/routes_rpc.py +89 -0
  29. package/extensions/services/evol/routes/routes_test.py +61 -0
  30. package/extensions/services/evol/server.py +875 -0
  31. package/extensions/services/evol/static/css/style.css +1200 -0
  32. package/extensions/services/evol/static/index.html +781 -0
  33. package/extensions/services/evol/static/index_evol.html +14 -0
  34. package/extensions/services/evol/static/js/app.js +6304 -0
  35. package/extensions/services/evol/static/js/auth.js +326 -0
  36. package/extensions/services/evol/static/js/dialog.js +285 -0
  37. package/extensions/services/evol/static/js/evol-app-fixed.js +50 -0
  38. package/extensions/services/evol/static/js/evol-app.js +1949 -0
  39. package/extensions/services/evol/static/js/evol-app.js.bak +1800 -0
  40. package/extensions/services/evol/static/js/kernel-client-example.js +228 -0
  41. package/extensions/services/evol/static/js/kernel-client.js +396 -0
  42. package/extensions/services/evol/static/js/main.js +141 -0
  43. package/extensions/services/evol/static/js/registry-tests.js +585 -0
  44. package/extensions/services/evol/static/js/stats.js +217 -0
  45. package/extensions/services/evol/static/js/token-manager.js +175 -0
  46. package/extensions/services/evol/static/pairing.html +248 -0
  47. package/extensions/services/evol/static/test_registry.html +262 -0
  48. package/extensions/services/evol/static/test_relay.html +462 -0
  49. package/extensions/services/evol/stats_manager.py +240 -0
  50. package/extensions/services/model_service/entry.py +167 -19
  51. package/extensions/services/model_service/module.md +21 -22
  52. package/extensions/services/proxy/.claude/settings.local.json +13 -0
  53. package/extensions/services/proxy/CHANGELOG_20260308.md +258 -0
  54. package/extensions/services/proxy/_fix_prints.py +133 -0
  55. package/extensions/services/proxy/_fix_prints2.py +87 -0
  56. package/extensions/services/proxy/agentcp/LICENCE +178 -0
  57. package/extensions/services/proxy/agentcp/README copy.md +85 -0
  58. package/extensions/services/proxy/agentcp/README.md +260 -0
  59. package/extensions/services/proxy/agentcp/__init__.py +16 -0
  60. package/extensions/services/proxy/agentcp/agent.py +4 -0
  61. package/extensions/services/proxy/agentcp/agentcp.py +2494 -0
  62. package/extensions/services/proxy/agentcp/agentprofile.json +89 -0
  63. package/extensions/services/proxy/agentcp/ap/__init__.py +16 -0
  64. package/extensions/services/proxy/agentcp/ap/ap_client.py +316 -0
  65. package/extensions/services/proxy/agentcp/assets/images/wechat_qr.png +0 -0
  66. package/extensions/services/proxy/agentcp/backup/metrics.json +31 -0
  67. package/extensions/services/proxy/agentcp/base/__init__.py +20 -0
  68. package/extensions/services/proxy/agentcp/base/auth_client.py +257 -0
  69. package/extensions/services/proxy/agentcp/base/client.py +112 -0
  70. package/extensions/services/proxy/agentcp/base/env.py +34 -0
  71. package/extensions/services/proxy/agentcp/base/html_util.py +336 -0
  72. package/extensions/services/proxy/agentcp/base/log.py +98 -0
  73. package/extensions/services/proxy/agentcp/ca/__init__.py +17 -0
  74. package/extensions/services/proxy/agentcp/ca/ca_client.py +414 -0
  75. package/extensions/services/proxy/agentcp/ca/ca_root.py +74 -0
  76. package/extensions/services/proxy/agentcp/context/__init__.py +20 -0
  77. package/extensions/services/proxy/agentcp/context/context.py +73 -0
  78. package/extensions/services/proxy/agentcp/context/exceptions.py +114 -0
  79. package/extensions/services/proxy/agentcp/create_profile.py +125 -0
  80. package/extensions/services/proxy/agentcp/create_profile_weather.py +125 -0
  81. package/extensions/services/proxy/agentcp/db/__init__.py +15 -0
  82. package/extensions/services/proxy/agentcp/db/db_mananger.py +550 -0
  83. package/extensions/services/proxy/agentcp/docs/UDP_HEARTBEAT_FIX_REPORT.md +265 -0
  84. package/extensions/services/proxy/agentcp/docs/heartbeat_issue_analysis.md +291 -0
  85. package/extensions/services/proxy/agentcp/file/__init__.py +16 -0
  86. package/extensions/services/proxy/agentcp/file/file_client.py +141 -0
  87. package/extensions/services/proxy/agentcp/file/wss_binary_message.py +137 -0
  88. package/extensions/services/proxy/agentcp/hcp.py +299 -0
  89. package/extensions/services/proxy/agentcp/heartbeat/__init__.py +16 -0
  90. package/extensions/services/proxy/agentcp/heartbeat/heartbeat_client.py +360 -0
  91. package/extensions/services/proxy/agentcp/improved_scheduler.py +498 -0
  92. package/extensions/services/proxy/agentcp/llm_agent_utils.py +249 -0
  93. package/extensions/services/proxy/agentcp/llm_server.py +172 -0
  94. package/extensions/services/proxy/agentcp/mermaid.py +210 -0
  95. package/extensions/services/proxy/agentcp/message.py +149 -0
  96. package/extensions/services/proxy/agentcp/metrics.py +256 -0
  97. package/extensions/services/proxy/agentcp/monitoring/__init__.py +20 -0
  98. package/extensions/services/proxy/agentcp/monitoring/global_monitor.py +27 -0
  99. package/extensions/services/proxy/agentcp/monitoring/metrics_store.py +325 -0
  100. package/extensions/services/proxy/agentcp/monitoring/monitoring_service.py +269 -0
  101. package/extensions/services/proxy/agentcp/monitoring/sliding_window.py +222 -0
  102. package/extensions/services/proxy/agentcp/monitoring/standalone_reader.py +224 -0
  103. package/extensions/services/proxy/agentcp/msg/__init__.py +21 -0
  104. package/extensions/services/proxy/agentcp/msg/connection_manager.py +456 -0
  105. package/extensions/services/proxy/agentcp/msg/message_client.py +2058 -0
  106. package/extensions/services/proxy/agentcp/msg/message_serialize.py +263 -0
  107. package/extensions/services/proxy/agentcp/msg/open_ai_message.py +88 -0
  108. package/extensions/services/proxy/agentcp/msg/session_manager.py +1062 -0
  109. package/extensions/services/proxy/agentcp/msg/stream_client.py +267 -0
  110. package/extensions/services/proxy/agentcp/msg/websocket_file_receiver.py +89 -0
  111. package/extensions/services/proxy/agentcp/msg/ws_logger.py +685 -0
  112. package/extensions/services/proxy/agentcp/msg/wss_binary_message.py +137 -0
  113. package/extensions/services/proxy/agentcp/requirements.txt +7 -0
  114. package/extensions/services/proxy/agentcp/samples/agent_graph/README.md +37 -0
  115. package/extensions/services/proxy/agentcp/samples/agent_graph/agentprofile.json +89 -0
  116. package/extensions/services/proxy/agentcp/samples/agent_graph/create_profile.py +138 -0
  117. package/extensions/services/proxy/agentcp/samples/agent_graph/main.py +164 -0
  118. package/extensions/services/proxy/agentcp/samples/agent_use/create_profile.py +123 -0
  119. package/extensions/services/proxy/agentcp/samples/agent_use/llm/create_profile.py +129 -0
  120. package/extensions/services/proxy/agentcp/samples/agent_use/llm/env.json +5 -0
  121. package/extensions/services/proxy/agentcp/samples/agent_use/llm/main.py +146 -0
  122. package/extensions/services/proxy/agentcp/samples/agent_use/main.py +123 -0
  123. package/extensions/services/proxy/agentcp/samples/agent_use/readme.md +379 -0
  124. package/extensions/services/proxy/agentcp/samples/agent_use/search/create_profile.py +129 -0
  125. package/extensions/services/proxy/agentcp/samples/agent_use/search/main.py +28 -0
  126. package/extensions/services/proxy/agentcp/samples/agent_use/tool/create_profile.py +129 -0
  127. package/extensions/services/proxy/agentcp/samples/agent_use/tool/main.py +20 -0
  128. package/extensions/services/proxy/agentcp/samples/ali_amap/README.md +97 -0
  129. package/extensions/services/proxy/agentcp/samples/ali_amap/amap_agent.py +88 -0
  130. package/extensions/services/proxy/agentcp/samples/ali_amap/create_profile.py +125 -0
  131. package/extensions/services/proxy/agentcp/samples/compute_agent/agent/powershell.py +228 -0
  132. package/extensions/services/proxy/agentcp/samples/compute_agent/agent/software.py +63 -0
  133. package/extensions/services/proxy/agentcp/samples/compute_agent/agent/tools.py +36 -0
  134. package/extensions/services/proxy/agentcp/samples/compute_agent/browser_user.py +41 -0
  135. package/extensions/services/proxy/agentcp/samples/deepseek/README.md +79 -0
  136. package/extensions/services/proxy/agentcp/samples/deepseek/create_profile.py +126 -0
  137. package/extensions/services/proxy/agentcp/samples/deepseek/deepseek.py +42 -0
  138. package/extensions/services/proxy/agentcp/samples/dify_chat/README.md +78 -0
  139. package/extensions/services/proxy/agentcp/samples/dify_chat/create_profile.py +126 -0
  140. package/extensions/services/proxy/agentcp/samples/dify_chat/dify_chat.py +47 -0
  141. package/extensions/services/proxy/agentcp/samples/dify_workflow/README.md +78 -0
  142. package/extensions/services/proxy/agentcp/samples/dify_workflow/create_profile.py +126 -0
  143. package/extensions/services/proxy/agentcp/samples/dify_workflow/dify_workflow.py +46 -0
  144. package/extensions/services/proxy/agentcp/samples/executor/README.md +44 -0
  145. package/extensions/services/proxy/agentcp/samples/executor/agentprofile.json +89 -0
  146. package/extensions/services/proxy/agentcp/samples/executor/create_profile.py +139 -0
  147. package/extensions/services/proxy/agentcp/samples/executor/main.py +160 -0
  148. package/extensions/services/proxy/agentcp/samples/filereader/README.md +45 -0
  149. package/extensions/services/proxy/agentcp/samples/filereader/agentprofile.json +90 -0
  150. package/extensions/services/proxy/agentcp/samples/filereader/create_profile.py +137 -0
  151. package/extensions/services/proxy/agentcp/samples/filereader/main.py +253 -0
  152. package/extensions/services/proxy/agentcp/samples/filewriter/README.md +38 -0
  153. package/extensions/services/proxy/agentcp/samples/filewriter/agentprofile.json +91 -0
  154. package/extensions/services/proxy/agentcp/samples/filewriter/create_profile.py +138 -0
  155. package/extensions/services/proxy/agentcp/samples/filewriter/main.py +289 -0
  156. package/extensions/services/proxy/agentcp/samples/hcp/README.md +85 -0
  157. package/extensions/services/proxy/agentcp/samples/hcp/acp_weather_agent.zip +0 -0
  158. package/extensions/services/proxy/agentcp/samples/hcp/create_profile.py +125 -0
  159. package/extensions/services/proxy/agentcp/samples/hcp/hcp.py +237 -0
  160. package/extensions/services/proxy/agentcp/samples/helloworld/README.md +68 -0
  161. package/extensions/services/proxy/agentcp/samples/helloworld/hello_world.py +40 -0
  162. package/extensions/services/proxy/agentcp/samples/llm_agent/MEADME.md +117 -0
  163. package/extensions/services/proxy/agentcp/samples/llm_agent/create_profile.py +125 -0
  164. package/extensions/services/proxy/agentcp/samples/llm_agent/qwen_agent.py +136 -0
  165. package/extensions/services/proxy/agentcp/samples/local_llm_agent/README.md +90 -0
  166. package/extensions/services/proxy/agentcp/samples/local_llm_agent/create_profile.py +125 -0
  167. package/extensions/services/proxy/agentcp/samples/local_llm_agent/main.py +49 -0
  168. package/extensions/services/proxy/agentcp/samples/query_llm_from_agent/README.md +55 -0
  169. package/extensions/services/proxy/agentcp/samples/query_llm_from_agent/create_profile.py +125 -0
  170. package/extensions/services/proxy/agentcp/samples/query_llm_from_agent/main.py +23 -0
  171. package/extensions/services/proxy/agentcp/samples/query_weather_api_agent/README.md +103 -0
  172. package/extensions/services/proxy/agentcp/samples/query_weather_api_agent/create_profile.py +125 -0
  173. package/extensions/services/proxy/agentcp/samples/query_weather_api_agent/main.py +69 -0
  174. package/extensions/services/proxy/agentcp/samples/query_weather_from_agent/README.md +58 -0
  175. package/extensions/services/proxy/agentcp/samples/query_weather_from_agent/create_profile.py +125 -0
  176. package/extensions/services/proxy/agentcp/samples/query_weather_from_agent/main.py +25 -0
  177. package/extensions/services/proxy/agentcp/samples/qwen3/README.md +71 -0
  178. package/extensions/services/proxy/agentcp/samples/qwen3/create_profile.py +126 -0
  179. package/extensions/services/proxy/agentcp/samples/qwen3/qwen3.py +37 -0
  180. package/extensions/services/proxy/agentcp/samples/qwen3_tools/README.md +133 -0
  181. package/extensions/services/proxy/agentcp/samples/qwen3_tools/create_profile.py +126 -0
  182. package/extensions/services/proxy/agentcp/samples/qwen3_tools/qwen3_tools.py +98 -0
  183. package/extensions/services/proxy/agentcp/samples/search/create_profile_qwen.py +125 -0
  184. package/extensions/services/proxy/agentcp/samples/search/create_profile_search.py +125 -0
  185. package/extensions/services/proxy/agentcp/samples/search/qwen_agent.py +136 -0
  186. package/extensions/services/proxy/agentcp/samples/search/search_agent.py +170 -0
  187. package/extensions/services/proxy/agentcp/samples/wrapper_agently_to_agent/README.md +89 -0
  188. package/extensions/services/proxy/agentcp/samples/wrapper_agently_to_agent/create_profile.py +125 -0
  189. package/extensions/services/proxy/agentcp/samples/wrapper_agently_to_agent/main.py +44 -0
  190. package/extensions/services/proxy/agentcp/utils/__init__.py +15 -0
  191. package/extensions/services/proxy/agentcp/utils/file_util.py +117 -0
  192. package/extensions/services/proxy/agentcp/utils/proxy_bypass.py +99 -0
  193. package/extensions/services/proxy/agentcp/workflow.py +203 -0
  194. package/extensions/services/proxy/console_auth.py +109 -0
  195. package/extensions/services/proxy/evol/__init__.py +1 -0
  196. package/extensions/services/proxy/evol/config.py +37 -0
  197. package/extensions/services/proxy/evol/http/__init__.py +1 -0
  198. package/extensions/services/proxy/evol/http/async_http.py +551 -0
  199. package/extensions/services/proxy/evol/log.py +28 -0
  200. package/extensions/services/proxy/evol/presenter/__init__.py +2 -0
  201. package/extensions/services/proxy/evol/presenter/agentIdPresenter.py +1031 -0
  202. package/extensions/services/proxy/evol/presenter/apikeyPresenter.py +106 -0
  203. package/extensions/services/proxy/evol/presenter/configPresenter.py +1281 -0
  204. package/extensions/services/proxy/evol/presenter/userPresenter.py +477 -0
  205. package/extensions/services/proxy/evol/server/__init__.py +1 -0
  206. package/extensions/services/proxy/evol/server/claude_proxy_async.py +3430 -0
  207. package/extensions/services/proxy/evol/server/openclaw_proxy.py +1861 -0
  208. package/extensions/services/proxy/evol/server/proxy_config.py +15 -0
  209. package/extensions/services/proxy/evol/server/proxy_engine.py +501 -0
  210. package/extensions/services/proxy/evol/version.py +24 -0
  211. package/extensions/services/proxy/logs/websocket.log +260 -0
  212. package/extensions/services/proxy/main.py +240 -0
  213. package/extensions/services/proxy/requirements.txt +13 -0
  214. package/extensions/services/proxy/server.py +271 -0
  215. package/extensions/services/watchdog/entry.py +215 -26
  216. package/extensions/services/watchdog/module.md +1 -0
  217. package/extensions/services/watchdog/monitor.py +178 -38
  218. package/extensions/services/web/WEBSOCKET_STATUS.md +143 -0
  219. package/extensions/services/web/config_example.py +35 -0
  220. package/extensions/services/web/config_loader.py +110 -0
  221. package/extensions/services/web/entry.py +114 -26
  222. package/extensions/services/web/module.md +35 -24
  223. package/extensions/services/web/pairing.py +250 -0
  224. package/extensions/services/web/pairing_codes.jsonl +16 -0
  225. package/extensions/services/web/relay.py +643 -0
  226. package/extensions/services/web/relay_config.json5 +67 -0
  227. package/extensions/services/web/routes/routes_management_ws.py +127 -0
  228. package/extensions/services/web/routes/routes_rpc.py +89 -0
  229. package/extensions/services/web/routes/routes_test.py +61 -0
  230. package/extensions/services/web/routes/schemas.py +0 -22
  231. package/extensions/services/web/server.py +434 -99
  232. package/extensions/services/web/static/css/style.css +67 -28
  233. package/extensions/services/web/static/index.html +234 -44
  234. package/extensions/services/web/static/js/app.js +1335 -48
  235. package/extensions/services/web/static/js/kernel-client-example.js +161 -0
  236. package/extensions/services/web/static/js/kernel-client.js +383 -0
  237. package/extensions/services/web/static/js/registry-tests.js +558 -0
  238. package/extensions/services/web/static/js/token-manager.js +175 -0
  239. package/extensions/services/web/static/pairing.html +248 -0
  240. package/extensions/services/web/static/test_registry.html +262 -0
  241. package/extensions/services/web/web_config.json5 +29 -0
  242. package/kernel/entry.py +120 -32
  243. package/kernel/event_hub.py +141 -16
  244. package/kernel/module.md +60 -33
  245. package/kernel/registry_store.py +45 -36
  246. package/kernel/rpc_router.py +152 -59
  247. package/kernel/server.py +322 -26
  248. package/kite_cli/__init__.py +3 -0
  249. package/kite_cli/__main__.py +5 -0
  250. package/kite_cli/commands/__init__.py +1 -0
  251. package/kite_cli/commands/clean.py +101 -0
  252. package/kite_cli/commands/deps_install.py +67 -0
  253. package/kite_cli/commands/doctor.py +35 -0
  254. package/kite_cli/commands/env_check.py +45 -0
  255. package/kite_cli/commands/history.py +111 -0
  256. package/kite_cli/commands/info.py +96 -0
  257. package/kite_cli/commands/install.py +313 -0
  258. package/kite_cli/commands/list.py +143 -0
  259. package/kite_cli/commands/log.py +81 -0
  260. package/kite_cli/commands/prepare.py +49 -0
  261. package/kite_cli/commands/rollback.py +88 -0
  262. package/kite_cli/commands/search.py +73 -0
  263. package/kite_cli/commands/uninstall.py +85 -0
  264. package/kite_cli/commands/update.py +118 -0
  265. package/kite_cli/commands/venv_setup.py +56 -0
  266. package/kite_cli/core/__init__.py +1 -0
  267. package/kite_cli/core/checker.py +142 -0
  268. package/kite_cli/core/dependency.py +229 -0
  269. package/kite_cli/core/downloader.py +209 -0
  270. package/kite_cli/core/install_info.py +40 -0
  271. package/kite_cli/core/tool_installer.py +397 -0
  272. package/kite_cli/core/validator.py +78 -0
  273. package/kite_cli/main.py +317 -0
  274. package/kite_cli/utils/__init__.py +1 -0
  275. package/kite_cli/utils/i18n.py +252 -0
  276. package/kite_cli/utils/interactive.py +63 -0
  277. package/kite_cli/utils/operation_log.py +77 -0
  278. package/kite_cli/utils/paths.py +34 -0
  279. package/kite_cli/utils/version.py +308 -0
  280. package/launcher/entry.py +1124 -178
  281. package/launcher/logging_setup.py +104 -0
  282. package/launcher/module.md +46 -37
  283. package/launcher/module_scanner.py +11 -1
  284. package/main.py +4 -1
  285. package/package.json +9 -1
  286. package/python_version.json +4 -0
  287. package/requirements.txt +38 -0
  288. package/scripts/env-manager.js +328 -0
  289. package/scripts/plan_manager.py +315 -0
  290. package/scripts/python-env.js +79 -0
  291. package/scripts/scan_dependencies.py +461 -0
  292. package/scripts/setup-python-env.js +191 -0
  293. package/extensions/services/web/routes/routes_modules.py +0 -249
@@ -8,13 +8,14 @@ Connects to Kernel via WebSocket JSON-RPC 2.0 for event publishing and subscript
8
8
  import asyncio
9
9
  import json
10
10
  import logging
11
+ import os
11
12
  import time
12
13
  import uuid
13
14
  from datetime import datetime, timezone
14
15
  from pathlib import Path
15
16
 
16
17
  import websockets
17
- from fastapi import FastAPI
18
+ from fastapi import FastAPI, WebSocket
18
19
  from fastapi.staticfiles import StaticFiles
19
20
 
20
21
  from vendor import config as cfg
@@ -30,11 +31,26 @@ from routes.routes_contacts import router as contacts_router
30
31
  from routes.routes_stats import router as stats_router
31
32
  from routes.routes_voicechat import router as voicechat_router
32
33
  from routes.routes_devlog import router as devlog_router
33
- from routes.routes_modules import router as modules_router
34
+ from routes.routes_rpc import router as rpc_router, set_web_server
35
+ from routes.routes_management_ws import router as management_ws_router, broadcast_event
36
+ from routes.routes_test import router as test_router
37
+
38
+ from config_loader import load_business_configs
39
+ from pairing import PairingManager
40
+ from relay import KernelRelay
34
41
 
35
42
  logger = logging.getLogger(__name__)
36
43
 
37
44
 
45
+ # System broadcast events (received by all modules, may not need handling)
46
+ SYSTEM_BROADCAST_EVENTS = {
47
+ "module.ready", "module.registered", "module.started", "module.stopped",
48
+ "module.crashed", "module.exiting", "module.offline",
49
+ "module.shutdown.ack", "module.shutdown.ready",
50
+ "system.ready", "registry.updated",
51
+ }
52
+
53
+
38
54
  class WebServer:
39
55
 
40
56
  def __init__(self, token: str = "", kernel_port: int = 0,
@@ -48,8 +64,10 @@ class WebServer:
48
64
  self._test_task: asyncio.Task | None = None
49
65
  self._ws: object | None = None
50
66
  self._shutting_down = False
67
+ self._exit_code = 0 # Exit code for main() to use
51
68
  self._uvicorn_server = None # set by entry.py for graceful shutdown
52
69
  self._start_time = time.time()
70
+ self._rpc_futures = {} # Store pending RPC request futures
53
71
  self.bt_manager: BluetoothManager | None = None
54
72
  self.task_manager: TaskManager | None = None
55
73
  self.app = self._create_app()
@@ -60,6 +78,40 @@ class WebServer:
60
78
 
61
79
  @app.on_event("startup")
62
80
  async def _startup():
81
+ # Load business configurations
82
+ module_dir = Path(__file__).parent
83
+ business_configs = load_business_configs(str(module_dir))
84
+
85
+ # Get relay service config
86
+ relay_business = business_configs.get('relay_service')
87
+ if relay_business:
88
+ relay_config = relay_business['config']
89
+
90
+ # Initialize pairing manager
91
+ auth_config = relay_config['auth']
92
+ pairing_file = module_dir / auth_config['pairing_code_file']
93
+ pairing_manager = PairingManager(
94
+ pairing_file=str(pairing_file),
95
+ code_length=auth_config['pairing_code_length'],
96
+ token_expiry=auth_config['token_expiry']
97
+ )
98
+ app.state.pairing_manager = pairing_manager
99
+ print(f"[web] Pairing manager initialized")
100
+
101
+ # Initialize relay service
102
+ relay_service = KernelRelay(
103
+ kernel_host="127.0.0.1",
104
+ kernel_port=server.kernel_port,
105
+ kernel_token=server.token,
106
+ base_module_id=relay_config['relay']['base_module_id'],
107
+ reconnect_timeout=relay_config['relay']['reconnect_timeout'],
108
+ permissions=relay_config['permissions'],
109
+ pairing_manager=pairing_manager,
110
+ web_server=server # 传递 web server 实例
111
+ )
112
+ app.state.relay_service = relay_service
113
+ print(f"[web] Relay service initialized")
114
+
63
115
  # Load configuration
64
116
  cfg.load_config()
65
117
  load_err = cfg.get_load_error()
@@ -142,12 +194,63 @@ class WebServer:
142
194
  app.include_router(stats_router, prefix="/api")
143
195
  app.include_router(voicechat_router) # no prefix (has own /ws/ and /api/ paths)
144
196
  app.include_router(devlog_router, prefix="/api")
145
- app.include_router(modules_router, prefix="/api")
197
+ app.include_router(rpc_router, prefix="/api")
198
+ app.include_router(test_router, prefix="/api")
199
+ app.include_router(management_ws_router) # /ws/management
200
+
201
+ # Relay WebSocket endpoint
202
+ @app.websocket("/ws/relay")
203
+ async def relay_endpoint(ws: WebSocket):
204
+ """Kernel 中转服务 WebSocket 端点"""
205
+ relay_service = app.state.relay_service
206
+ if relay_service:
207
+ await relay_service.handle_client(ws)
208
+ else:
209
+ await ws.close(code=1011, reason="Relay service not initialized")
210
+
211
+ # Set web server reference for RPC forwarding
212
+ set_web_server(server)
146
213
 
147
214
  # Serve frontend static files
215
+ # IMPORTANT: Do NOT use app.mount("/", ...) as it will intercept WebSocket routes
216
+ # Instead, explicitly serve HTML files and static assets
148
217
  static_dir = Path(__file__).parent / "static"
149
218
  if static_dir.exists():
150
- app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="web")
219
+ from fastapi.responses import FileResponse, JSONResponse
220
+
221
+ @app.get("/")
222
+ async def serve_index():
223
+ """Serve index.html at root path."""
224
+ index_path = static_dir / "index.html"
225
+ if index_path.exists():
226
+ return FileResponse(index_path)
227
+ return {"message": "Kite Web Management"}
228
+
229
+ @app.get("/pairing.html")
230
+ async def serve_pairing():
231
+ """Serve pairing.html."""
232
+ pairing_path = static_dir / "pairing.html"
233
+ if pairing_path.exists():
234
+ return FileResponse(pairing_path)
235
+ return JSONResponse({"error": "Not found"}, status_code=404)
236
+
237
+ # Mount static files at /static prefix to avoid route conflicts
238
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
239
+
240
+ # Also serve common static paths directly from root for backward compatibility
241
+ @app.get("/js/{file_path:path}")
242
+ async def serve_js(file_path: str):
243
+ file = static_dir / "js" / file_path
244
+ if file.exists() and file.is_file():
245
+ return FileResponse(file)
246
+ return JSONResponse({"error": "Not found"}, status_code=404)
247
+
248
+ @app.get("/css/{file_path:path}")
249
+ async def serve_css(file_path: str):
250
+ file = static_dir / "css" / file_path
251
+ if file.exists() and file.is_file():
252
+ return FileResponse(file)
253
+ return JSONResponse({"error": "Not found"}, status_code=404)
151
254
 
152
255
  return app
153
256
 
@@ -165,6 +268,7 @@ class WebServer:
165
268
  retry_delay = 0.3 # reset on successful connection
166
269
  attempt = 0
167
270
  except asyncio.CancelledError:
271
+ print(f"[web] WS loop cancelled")
168
272
  return
169
273
  except Exception as e:
170
274
  attempt += 1
@@ -173,13 +277,25 @@ class WebServer:
173
277
  code = e.rcvd.code if hasattr(e.rcvd, 'code') else 0
174
278
  if code in (4001, 4003):
175
279
  print(f"[web] Kernel 认证失败 (code {code}),退出")
176
- import sys; sys.exit(1)
280
+ self._exit_code = 1
281
+ self._shutting_down = True
282
+ if self._uvicorn_server:
283
+ self._uvicorn_server.should_exit = True
284
+ return
177
285
  if attempt >= max_retries:
178
286
  print(f"[web] Kernel 重连失败 {max_retries} 次,退出")
179
- import sys; sys.exit(1)
287
+ self._exit_code = 1
288
+ self._shutting_down = True
289
+ if self._uvicorn_server:
290
+ self._uvicorn_server.should_exit = True
291
+ return
292
+ if self._shutting_down:
293
+ print(f"[web] Shutting down, not retrying connection")
294
+ return
180
295
  print(f"[web] Kernel connection error: {e}, retrying in {retry_delay:.1f}s ({attempt}/{max_retries})")
181
296
  self._ws = None
182
297
  if self._shutting_down:
298
+ print(f"[web] Shutting down, exiting WS loop")
183
299
  return
184
300
  await asyncio.sleep(retry_delay)
185
301
  retry_delay = min(retry_delay * 2, max_delay)
@@ -188,86 +304,135 @@ class WebServer:
188
304
  """Single WebSocket session: connect, register, subscribe, receive loop."""
189
305
  url = f"ws://127.0.0.1:{self.kernel_port}/ws?token={self.token}&id=web"
190
306
  print(f"[web] WS connecting to Kernel")
191
- async with websockets.connect(url, open_timeout=5, ping_interval=None, ping_timeout=None, close_timeout=10) as ws:
192
- self._ws = ws
193
- elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
194
- elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
195
- print(f"[web] Connected to Kernel{elapsed_str}")
196
-
197
- # Subscribe to events
198
- await self._rpc_call(ws, "event.subscribe", {
199
- "events": [
200
- "module.started",
201
- "module.stopped",
202
- "module.shutdown",
203
- ],
204
- })
205
-
206
- # Register to Kernel Registry via RPC
207
- await self._rpc_call(ws, "registry.register", {
208
- "module_id": "web",
209
- "module_type": "service",
210
- "api_endpoint": f"http://127.0.0.1:{self.port}",
211
- "health_endpoint": "/health",
212
- "events_publish": {
213
- "web.test": {"description": "Test event from web module"},
214
- "web.started": {"description": "Web UI started with access URL"},
215
- },
216
- "events_subscribe": [
217
- "module.started",
218
- "module.stopped",
219
- "module.shutdown",
220
- ],
221
- })
222
- print(f"[web] Registered to Kernel{elapsed_str}")
223
-
224
- # Send module.ready (every reconnect, not just first time)
225
- if not self._shutting_down:
226
- await self._rpc_call(ws, "event.publish", {
227
- "event_id": str(uuid.uuid4()),
228
- "event": "module.ready",
229
- "data": {
230
- "module_id": "web",
231
- "graceful_shutdown": True,
232
- },
233
- })
307
+ try:
308
+ async with websockets.connect(url, open_timeout=5, ping_interval=None, close_timeout=10) as ws:
309
+ self._ws = ws
234
310
  elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
235
311
  elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
236
- print(f"[web] module.ready sent{elapsed_str}")
237
-
238
- # Publish web.started event with access URL
239
- display_host = "localhost" if self.host == "0.0.0.0" else self.host
240
- access_url = f"http://{display_host}:{self.port}"
241
- await self._publish_event({
242
- "event": "web.started",
243
- "data": {
244
- "module_id": "web",
245
- "url": access_url,
246
- "host": self.host,
247
- "port": self.port,
248
- },
312
+ print(f"[web] Connected to Kernel{elapsed_str}")
313
+
314
+ # Subscribe to events
315
+ await self._rpc_call(ws, "event.subscribe", {
316
+ "events": [
317
+ "module.started",
318
+ "module.stopped",
319
+ "module.crashed",
320
+ "module.ready",
321
+ "module.exiting",
322
+ "module.shutdown",
323
+ "module.shutdown.ack",
324
+ "module.shutdown.ready",
325
+ ],
249
326
  })
250
327
 
251
- # Receive loop
252
- async for raw in ws:
253
- try:
254
- msg = json.loads(raw)
255
- except (json.JSONDecodeError, TypeError):
256
- continue
328
+ # Register to Kernel Registry via RPC
329
+ await self._rpc_call(ws, "registry.register", {
330
+ "module_id": "web",
331
+ "module_type": "service",
332
+ "api_endpoint": f"http://127.0.0.1:{self.port}",
333
+ "health_endpoint": "/health",
334
+ "tools": {
335
+ "rpc": {
336
+ "module": {
337
+ "health": {"method": "health", "description": "健康检查"},
338
+ "status": {"method": "status", "description": "状态查询"}
339
+ },
340
+ "web": {
341
+ "list_tokens": {"method": "list_tokens", "description": "列出所有令牌"},
342
+ "revoke_token": {"method": "revoke_token", "description": "撤销令牌"}
343
+ }
344
+ }
345
+ },
346
+ "events_publish": {
347
+ "web": {
348
+ "test": {"description": "Test event from web module"},
349
+ "started": {"description": "Web UI started with access URL"},
350
+ }
351
+ },
352
+ "events_subscribe": [
353
+ "module.started",
354
+ "module.stopped",
355
+ "module.crashed",
356
+ "module.ready",
357
+ "module.exiting",
358
+ "module.shutdown",
359
+ "module.shutdown.ack",
360
+ "module.shutdown.ready",
361
+ ],
362
+ })
363
+ print(f"[web] Registered to Kernel{elapsed_str}")
364
+
365
+ # Send module.ready (every reconnect, not just first time)
366
+ if not self._shutting_down:
367
+ startup_time = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
368
+ await self._rpc_call(ws, "event.publish", {
369
+ "event_id": str(uuid.uuid4()),
370
+ "event": "module.ready",
371
+ "data": {
372
+ "module_id": "web",
373
+ "graceful_shutdown": True,
374
+ "startup_time": startup_time,
375
+ },
376
+ })
377
+ elapsed_str = self._fmt_elapsed(self.boot_t0)
378
+ print(f"[web] module.ready published ({elapsed_str})")
379
+
380
+ # Publish web.started event with access URL
381
+ display_host = "localhost" if self.host == "0.0.0.0" else self.host
382
+ access_url = f"http://{display_host}:{self.port}"
383
+ await self._publish_event({
384
+ "event": "web.started",
385
+ "data": {
386
+ "module_id": "web",
387
+ "url": access_url,
388
+ "host": self.host,
389
+ "port": self.port,
390
+ },
391
+ })
392
+
393
+ # Receive loop
394
+ # CRITICAL: RPC 死锁防范
395
+ # - 入站 RPC 请求必须用 create_task() 异步执行,不可 await
396
+ # - 原因:如果 handler 内部调用 rpc_call() 发出站请求,出站响应需要本接收循环来分发
397
+ # - 如果接收循环被 await handler 阻塞,出站响应永远收不到 → 超时死锁
398
+ # - 事件通知和 RPC 响应可以同步处理(它们不会反向调用 rpc_call)
399
+ print(f"[web] Entering receive loop")
257
400
 
258
401
  try:
259
- has_method = "method" in msg
260
- has_id = "id" in msg
261
-
262
- if has_method and not has_id:
263
- # Event Notification
264
- await self._handle_event_notification(msg)
265
- elif has_method and has_id:
266
- # Incoming RPC request
267
- await self._handle_rpc_request(ws, msg)
268
- # Ignore RPC responses (we don't await them in this simple impl)
402
+ async for raw in ws:
403
+ try:
404
+ msg = json.loads(raw)
405
+ except (json.JSONDecodeError, TypeError):
406
+ continue
407
+
408
+ try:
409
+ has_method = "method" in msg
410
+ has_id = "id" in msg
411
+ has_result_or_error = "result" in msg or "error" in msg
412
+
413
+ if has_method and not has_id:
414
+ # Event Notification
415
+ await self._handle_event_notification(msg)
416
+ elif has_method and has_id:
417
+ # Incoming RPC request — run in background to prevent deadlock
418
+ asyncio.create_task(self._handle_rpc_request(ws, msg))
419
+ elif has_id and has_result_or_error:
420
+ # RPC response — resolve pending future
421
+ rpc_id = msg.get("id")
422
+ if rpc_id in self._rpc_futures:
423
+ self._rpc_futures[rpc_id].set_result(msg)
424
+ except Exception as e:
425
+ print(f"[web] 消息处理异常(已忽略): {e}")
269
426
  except Exception as e:
270
- print(f"[web] 消息处理异常(已忽略): {e}")
427
+ print(f"[web] Receive loop exited with exception: {e}")
428
+ finally:
429
+ print(f"[web] Receive loop ended")
430
+ except Exception as e:
431
+ print(f"[web] WebSocket connection error: {e}")
432
+ raise
433
+ finally:
434
+ print(f"[web] WebSocket connection closed")
435
+ self._ws = None
271
436
 
272
437
  async def _rpc_call(self, ws, method: str, params: dict = None):
273
438
  """Send a JSON-RPC 2.0 request (fire-and-forget, no response awaited)."""
@@ -276,23 +441,69 @@ class WebServer:
276
441
  msg["params"] = params
277
442
  await ws.send(json.dumps(msg))
278
443
 
444
+ async def _handle_ping_event(self, data: dict):
445
+ """Handle system.ping event and reply with system.pong."""
446
+ import time
447
+ t1 = data.get("ping_time")
448
+ t2 = time.time()
449
+
450
+ await self._publish_event({
451
+ "event": "system.pong",
452
+ "data": {
453
+ "module_id": "web",
454
+ "ping_time": t1,
455
+ "pong_time": t2,
456
+ },
457
+ })
458
+
279
459
  async def _handle_event_notification(self, msg: dict):
280
460
  """Handle an event notification (JSON-RPC 2.0 Notification with method='event')."""
281
461
  params = msg.get("params", {})
282
462
  event_type = params.get("event", "")
283
463
  data = params.get("data", {})
284
464
 
465
+ # Handle system.ping event
466
+ if event_type == "system.ping":
467
+ await self._handle_ping_event(data)
468
+ return
469
+
470
+ # Log all events for debugging
471
+ print(f"[web] Event received: {event_type}, data: {data}")
472
+
285
473
  # Special handling for module.shutdown
286
474
  if event_type == "module.shutdown":
287
475
  target = data.get("module_id", "")
288
476
  reason = data.get("reason", "")
477
+ print(f"[web] Shutdown event: target={target}, reason={reason}")
289
478
  # Handle both targeted shutdown (module_id == "web") and broadcast shutdown (no module_id or launcher_lost)
290
479
  if target == "web" or not target or reason == "launcher_lost":
480
+ print(f"[web] Handling shutdown...")
291
481
  await self._handle_shutdown()
292
482
  return
483
+ else:
484
+ print(f"[web] Ignoring shutdown (not for us)")
485
+ return
293
486
 
294
- # Log other events
295
- print(f"[web] Event received: {event_type}")
487
+ # Forward module status events to management WebSocket clients
488
+ if event_type in (
489
+ "module.started",
490
+ "module.stopped",
491
+ "module.crashed",
492
+ "module.ready",
493
+ "module.exiting",
494
+ "module.shutdown.ack",
495
+ "module.shutdown.ready",
496
+ ):
497
+ await broadcast_event(event_type, data)
498
+ return
499
+
500
+ # Layer 2: 忽略其他系统广播事件
501
+ if event_type in SYSTEM_BROADCAST_EVENTS:
502
+ return
503
+
504
+ # Layer 3: 警告未知事件(仅开发环境)
505
+ if os.environ.get("KITE_ENV") == "development":
506
+ print(f"[web] Debug: Unhandled event: {event_type}")
296
507
 
297
508
  async def _handle_rpc_request(self, ws, msg: dict):
298
509
  """Handle an incoming RPC request (web.* methods)."""
@@ -300,9 +511,15 @@ class WebServer:
300
511
  method = msg.get("method", "")
301
512
  params = msg.get("params", {})
302
513
 
514
+ # Strip "web." prefix if present
515
+ if method.startswith("web."):
516
+ method = method[4:]
517
+
303
518
  handlers = {
304
519
  "health": lambda p: self._rpc_health(),
305
520
  "status": lambda p: self._rpc_status(),
521
+ "list_tokens": lambda p: self._rpc_list_tokens(),
522
+ "revoke_token": lambda p: self._rpc_revoke_token(p),
306
523
  }
307
524
  handler = handlers.get(method)
308
525
  if handler:
@@ -322,10 +539,15 @@ class WebServer:
322
539
 
323
540
  async def _rpc_health(self) -> dict:
324
541
  """RPC handler for web.health."""
542
+ # 获取 WebSocket 连接数
543
+ from routes.routes_management_ws import _management_clients
544
+ active_connections = len(_management_clients)
545
+
325
546
  return {
326
547
  "status": "healthy",
548
+ "uptime_seconds": round(time.time() - self._start_time),
327
549
  "details": {
328
- "uptime_seconds": round(time.time() - self._start_time),
550
+ "active_ws_connections": active_connections,
329
551
  },
330
552
  }
331
553
 
@@ -337,50 +559,163 @@ class WebServer:
337
559
  "uptime_seconds": round(time.time() - self._start_time),
338
560
  }
339
561
 
562
+ async def _rpc_list_tokens(self) -> dict:
563
+ """RPC handler for web.list_tokens."""
564
+ from extensions.services.web.pairing import PairingManager
565
+ from pathlib import Path
566
+
567
+ # Get pairing manager from app state
568
+ pairing_manager = self.app.state.pairing_manager
569
+ if not pairing_manager:
570
+ # Fallback: create temporary instance
571
+ pairing_file = Path(__file__).parent / "pairing_codes.jsonl"
572
+ pairing_manager = PairingManager(str(pairing_file))
573
+
574
+ # Read all token records
575
+ codes = pairing_manager._read_codes()
576
+
577
+ # Build a set of revoked tokens
578
+ revoked_tokens = {
579
+ record.get("token")
580
+ for record in codes
581
+ if record.get("status") == "revoked" and record.get("token")
582
+ }
583
+
584
+ # Filter only used tokens (status="used") that are not revoked
585
+ tokens = [
586
+ {
587
+ "token": record.get("token"),
588
+ "role": record.get("role", "viewer"),
589
+ "paired_at": record.get("paired_at"),
590
+ "expires_at": record.get("expires_at"),
591
+ "code": record.get("code", "")
592
+ }
593
+ for record in codes
594
+ if record.get("status") == "used"
595
+ and record.get("token")
596
+ and record.get("token") not in revoked_tokens
597
+ ]
598
+
599
+ return {"tokens": tokens}
600
+
601
+ async def _rpc_revoke_token(self, params: dict) -> dict:
602
+ """RPC handler for web.revoke_token."""
603
+ from extensions.services.web.pairing import PairingManager
604
+ from pathlib import Path
605
+
606
+ token = params.get("token")
607
+ if not token:
608
+ raise ValueError("Missing token parameter")
609
+
610
+ # Get pairing manager from app state
611
+ pairing_manager = self.app.state.pairing_manager
612
+ if not pairing_manager:
613
+ # Fallback: create temporary instance
614
+ pairing_file = Path(__file__).parent / "pairing_codes.jsonl"
615
+ pairing_manager = PairingManager(str(pairing_file))
616
+
617
+ # Verify token exists
618
+ token_info = pairing_manager.verify_token(token)
619
+ if not token_info:
620
+ raise ValueError("Token not found or already expired")
621
+
622
+ # Write a revoked record
623
+ revoked_record = {
624
+ "token": token,
625
+ "status": "revoked",
626
+ "revoked_at": datetime.now(timezone.utc).isoformat()
627
+ }
628
+ pairing_manager._write_code(revoked_record)
629
+
630
+ return {"success": True, "message": "Token revoked successfully"}
631
+
340
632
  async def _handle_shutdown(self):
341
- """Handle module.shutdown: exitingack → cleanup → ready → exit."""
633
+ """Handle module.shutdown: ackexiting → cleanup → ready → close connections → exit."""
634
+ print("[web] ========== SHUTDOWN STARTED ==========")
342
635
  print("[web] Received module.shutdown")
343
636
  self._shutting_down = True
344
637
 
345
- # Step 0: Send module.exiting
638
+ # Step 1: Send ack (立即确认收到)
639
+ print("[web] Sending shutdown ack...")
346
640
  await self._publish_event({
347
- "event": "module.exiting",
348
- "data": {"module_id": "web", "action": "none"},
641
+ "event": "module.shutdown.ack",
642
+ "data": {"module_id": "web"},
349
643
  })
644
+ print("[web] shutdown ack sent")
350
645
 
351
- # Step 1: Send ack
646
+ # Step 2: Send module.exiting (开始清理)
647
+ print("[web] Sending module.exiting...")
352
648
  await self._publish_event({
353
- "event": "module.shutdown.ack",
354
- "data": {"module_id": "web", "estimated_cleanup": 2},
649
+ "event": "module.exiting",
650
+ "data": {
651
+ "module_id": "web",
652
+ "type": "passive",
653
+ "reason": "shutdown_requested",
654
+ "restart": "auto",
655
+ "action": "none",
656
+ "timeout": 3.0,
657
+ "restart_delay": 0.0,
658
+ },
355
659
  })
356
- print("[web] shutdown ack sent")
660
+ print("[web] module.exiting sent")
357
661
 
358
- # Step 2: Cleanup (cancel background tasks)
662
+ # Step 3: Cleanup (cancel background tasks)
663
+ print("[web] Cleaning up background tasks...")
359
664
  if self._test_task:
360
665
  self._test_task.cancel()
666
+ print("[web] Test task cancelled")
361
667
  if self.bt_manager:
362
668
  await self.bt_manager.stop()
669
+ print("[web] Bluetooth manager stopped")
670
+
671
+ # Step 3: Close all WebSocket connections gracefully
672
+ print("[web] Closing WebSocket connections...")
363
673
 
364
- # Step 3: Send ready (before closing WS!)
674
+ # Close relay sessions
675
+ if hasattr(self.app.state, 'relay_service'):
676
+ await self.app.state.relay_service.close_all_sessions()
677
+
678
+ # Close management clients
679
+ from .routes.routes_management_ws import close_all_clients
680
+ await close_all_clients()
681
+
682
+ print("[web] All WebSocket connections closed")
683
+
684
+ # Step 4: Send ready (after closing connections)
685
+ print("[web] Sending shutdown ready...")
365
686
  await self._publish_event({
366
687
  "event": "module.shutdown.ready",
367
688
  "data": {"module_id": "web"},
368
689
  })
369
- print("[web] Shutdown complete")
690
+ print("[web] shutdown ready sent")
370
691
 
371
- # Step 4: Trigger uvicorn exit (WS will close when uvicorn shuts down)
692
+ # Step 5: Trigger uvicorn graceful shutdown
693
+ print("[web] Triggering uvicorn graceful shutdown...")
372
694
  if self._uvicorn_server:
373
695
  self._uvicorn_server.should_exit = True
696
+ print("[web] uvicorn.should_exit = True")
697
+
698
+ print("[web] ========== SHUTDOWN COMPLETE ==========")
699
+
700
+ # Give uvicorn a moment to start shutdown, then force exit
701
+ # This prevents hanging on lingering connections
702
+ await asyncio.sleep(0.5)
703
+ import sys
704
+ sys.exit(0)
374
705
 
375
706
  async def _publish_event(self, event: dict):
376
707
  """Publish an event via RPC event.publish."""
377
708
  if not self._ws:
709
+ print(f"[web] WARNING: Cannot publish event {event.get('event')}, WebSocket not connected")
378
710
  return
379
- await self._rpc_call(self._ws, "event.publish", {
380
- "event_id": str(uuid.uuid4()),
381
- "event": event.get("event", ""),
382
- "data": event.get("data", {}),
383
- })
711
+ try:
712
+ await self._rpc_call(self._ws, "event.publish", {
713
+ "event_id": str(uuid.uuid4()),
714
+ "event": event.get("event", ""),
715
+ "data": event.get("data", {}),
716
+ })
717
+ except Exception as e:
718
+ print(f"[web] ERROR: Failed to publish event {event.get('event')}: {e}")
384
719
 
385
720
  # ── Test event loop ──
386
721