@aryacode/dsh-engineering 0.0.0-stage → 0.3.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 (281) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -2
  4. package/cordis.patch.yml +122 -0
  5. package/locale/en.json +6 -0
  6. package/locale/zh.json +6 -0
  7. package/package.json +77 -3
  8. package/vendor/ftview/README.md +55 -0
  9. package/vendor/ftview/SKILL.md +74 -0
  10. package/vendor/ftview/lib/checks.d.ts +27 -0
  11. package/vendor/ftview/lib/checks.js +39 -0
  12. package/vendor/ftview/lib/core.d.ts +208 -0
  13. package/vendor/ftview/lib/core.js +160 -0
  14. package/vendor/ftview/lib/edit/drafts.d.ts +49 -0
  15. package/vendor/ftview/lib/edit/drafts.js +84 -0
  16. package/vendor/ftview/lib/edit/operations.d.ts +62 -0
  17. package/vendor/ftview/lib/edit/operations.js +126 -0
  18. package/vendor/ftview/lib/edit/prepare.d.ts +38 -0
  19. package/vendor/ftview/lib/edit/prepare.js +133 -0
  20. package/vendor/ftview/lib/edit/tools.d.ts +25 -0
  21. package/vendor/ftview/lib/edit/tools.js +198 -0
  22. package/vendor/ftview/lib/edit.d.ts +30 -0
  23. package/vendor/ftview/lib/edit.js +38 -0
  24. package/vendor/ftview/lib/index.d.ts +31 -0
  25. package/vendor/ftview/lib/index.js +36 -0
  26. package/vendor/ftview/lib/model.d.ts +80 -0
  27. package/vendor/ftview/lib/model.js +267 -0
  28. package/vendor/ftview/lib/session.d.ts +48 -0
  29. package/vendor/ftview/lib/session.js +98 -0
  30. package/vendor/ftview/lib/skill.d.ts +29 -0
  31. package/vendor/ftview/lib/skill.js +43 -0
  32. package/vendor/ftview/lib/studio/discover.d.ts +36 -0
  33. package/vendor/ftview/lib/studio/discover.js +87 -0
  34. package/vendor/ftview/lib/studio/preflight.d.ts +51 -0
  35. package/vendor/ftview/lib/studio/preflight.js +64 -0
  36. package/vendor/ftview/lib/studio/tools.d.ts +15 -0
  37. package/vendor/ftview/lib/studio/tools.js +83 -0
  38. package/vendor/ftview/lib/studio.d.ts +30 -0
  39. package/vendor/ftview/lib/studio.js +37 -0
  40. package/vendor/ftview/lib/tools.d.ts +14 -0
  41. package/vendor/ftview/lib/tools.js +202 -0
  42. package/vendor/l5x/README.md +62 -0
  43. package/vendor/l5x/SKILL.md +81 -0
  44. package/vendor/l5x/lib/approvals.d.ts +50 -0
  45. package/vendor/l5x/lib/approvals.js +93 -0
  46. package/vendor/l5x/lib/client.js +412 -0
  47. package/vendor/l5x/lib/client.js.map +1 -0
  48. package/vendor/l5x/lib/core/errors.d.ts +38 -0
  49. package/vendor/l5x/lib/core/errors.js +48 -0
  50. package/vendor/l5x/lib/core/params.d.ts +49 -0
  51. package/vendor/l5x/lib/core/params.js +39 -0
  52. package/vendor/l5x/lib/core/result.d.ts +46 -0
  53. package/vendor/l5x/lib/core/result.js +21 -0
  54. package/vendor/l5x/lib/core/tool.d.ts +102 -0
  55. package/vendor/l5x/lib/core/tool.js +19 -0
  56. package/vendor/l5x/lib/drafts.d.ts +66 -0
  57. package/vendor/l5x/lib/drafts.js +98 -0
  58. package/vendor/l5x/lib/editing.d.ts +30 -0
  59. package/vendor/l5x/lib/editing.js +42 -0
  60. package/vendor/l5x/lib/engine/acd-edit.d.ts +13 -0
  61. package/vendor/l5x/lib/engine/acd-edit.js +115 -0
  62. package/vendor/l5x/lib/engine/analysis.d.ts +22 -0
  63. package/vendor/l5x/lib/engine/analysis.js +258 -0
  64. package/vendor/l5x/lib/engine/capabilities.d.ts +9 -0
  65. package/vendor/l5x/lib/engine/capabilities.js +40 -0
  66. package/vendor/l5x/lib/engine/change-set.d.ts +122 -0
  67. package/vendor/l5x/lib/engine/change-set.js +446 -0
  68. package/vendor/l5x/lib/engine/configuration.d.ts +21 -0
  69. package/vendor/l5x/lib/engine/configuration.js +42 -0
  70. package/vendor/l5x/lib/engine/details.d.ts +47 -0
  71. package/vendor/l5x/lib/engine/details.js +173 -0
  72. package/vendor/l5x/lib/engine/direct-polling.d.ts +91 -0
  73. package/vendor/l5x/lib/engine/direct-polling.js +193 -0
  74. package/vendor/l5x/lib/engine/download-review.d.ts +15 -0
  75. package/vendor/l5x/lib/engine/download-review.js +63 -0
  76. package/vendor/l5x/lib/engine/logic-syntax.d.ts +60 -0
  77. package/vendor/l5x/lib/engine/logic-syntax.js +237 -0
  78. package/vendor/l5x/lib/engine/observations.d.ts +41 -0
  79. package/vendor/l5x/lib/engine/observations.js +67 -0
  80. package/vendor/l5x/lib/engine/project-analysis.d.ts +131 -0
  81. package/vendor/l5x/lib/engine/project-analysis.js +314 -0
  82. package/vendor/l5x/lib/engine/review-evidence.d.ts +8 -0
  83. package/vendor/l5x/lib/engine/review-evidence.js +49 -0
  84. package/vendor/l5x/lib/engine/review.d.ts +18 -0
  85. package/vendor/l5x/lib/engine/review.js +123 -0
  86. package/vendor/l5x/lib/engine/studio-sdk.d.ts +101 -0
  87. package/vendor/l5x/lib/engine/studio-sdk.js +571 -0
  88. package/vendor/l5x/lib/engine/tag-resolution.d.ts +20 -0
  89. package/vendor/l5x/lib/engine/tag-resolution.js +169 -0
  90. package/vendor/l5x/lib/engine/usage.d.ts +16 -0
  91. package/vendor/l5x/lib/engine/usage.js +92 -0
  92. package/vendor/l5x/lib/engine/xml-regions.d.ts +23 -0
  93. package/vendor/l5x/lib/engine/xml-regions.js +77 -0
  94. package/vendor/l5x/lib/fs.d.ts +17 -0
  95. package/vendor/l5x/lib/fs.js +36 -0
  96. package/vendor/l5x/lib/index.d.ts +36 -0
  97. package/vendor/l5x/lib/index.js +42 -0
  98. package/vendor/l5x/lib/live.d.ts +48 -0
  99. package/vendor/l5x/lib/live.js +48 -0
  100. package/vendor/l5x/lib/model/aois.d.ts +39 -0
  101. package/vendor/l5x/lib/model/aois.js +112 -0
  102. package/vendor/l5x/lib/model/errors.d.ts +15 -0
  103. package/vendor/l5x/lib/model/errors.js +18 -0
  104. package/vendor/l5x/lib/model/index.d.ts +20 -0
  105. package/vendor/l5x/lib/model/index.js +19 -0
  106. package/vendor/l5x/lib/model/module.d.ts +37 -0
  107. package/vendor/l5x/lib/model/module.js +54 -0
  108. package/vendor/l5x/lib/model/modules.d.ts +24 -0
  109. package/vendor/l5x/lib/model/modules.js +55 -0
  110. package/vendor/l5x/lib/model/parse.d.ts +32 -0
  111. package/vendor/l5x/lib/model/parse.js +116 -0
  112. package/vendor/l5x/lib/model/project.d.ts +15 -0
  113. package/vendor/l5x/lib/model/project.js +31 -0
  114. package/vendor/l5x/lib/model/routines.d.ts +71 -0
  115. package/vendor/l5x/lib/model/routines.js +183 -0
  116. package/vendor/l5x/lib/model/tags.d.ts +51 -0
  117. package/vendor/l5x/lib/model/tags.js +119 -0
  118. package/vendor/l5x/lib/model/types.d.ts +304 -0
  119. package/vendor/l5x/lib/model/types.js +12 -0
  120. package/vendor/l5x/lib/model/udts.d.ts +34 -0
  121. package/vendor/l5x/lib/model/udts.js +81 -0
  122. package/vendor/l5x/lib/model/xml.d.ts +139 -0
  123. package/vendor/l5x/lib/model/xml.js +291 -0
  124. package/vendor/l5x/lib/model/xref.d.ts +28 -0
  125. package/vendor/l5x/lib/model/xref.js +154 -0
  126. package/vendor/l5x/lib/session.d.ts +119 -0
  127. package/vendor/l5x/lib/session.js +236 -0
  128. package/vendor/l5x/lib/skill.d.ts +30 -0
  129. package/vendor/l5x/lib/skill.js +44 -0
  130. package/vendor/l5x/lib/studio-state.d.ts +77 -0
  131. package/vendor/l5x/lib/studio-state.js +79 -0
  132. package/vendor/l5x/lib/studio.d.ts +29 -0
  133. package/vendor/l5x/lib/studio.js +40 -0
  134. package/vendor/l5x/lib/tools/checks.d.ts +19 -0
  135. package/vendor/l5x/lib/tools/checks.js +140 -0
  136. package/vendor/l5x/lib/tools/common.d.ts +41 -0
  137. package/vendor/l5x/lib/tools/common.js +84 -0
  138. package/vendor/l5x/lib/tools/controller.d.ts +21 -0
  139. package/vendor/l5x/lib/tools/controller.js +170 -0
  140. package/vendor/l5x/lib/tools/drafts.d.ts +23 -0
  141. package/vendor/l5x/lib/tools/drafts.js +270 -0
  142. package/vendor/l5x/lib/tools/live.d.ts +21 -0
  143. package/vendor/l5x/lib/tools/live.js +175 -0
  144. package/vendor/l5x/lib/tools/read.d.ts +19 -0
  145. package/vendor/l5x/lib/tools/read.js +245 -0
  146. package/vendor/l5x/lib/tools/references.d.ts +20 -0
  147. package/vendor/l5x/lib/tools/references.js +269 -0
  148. package/vendor/l5x/lib/tools/studio.d.ts +21 -0
  149. package/vendor/l5x/lib/tools/studio.js +193 -0
  150. package/vendor/l5x/poll-worker.mjs +77 -0
  151. package/vendor/l5x/studio-bridge/NuGet.Config +1 -0
  152. package/vendor/l5x/studio-bridge/Program.cs +304 -0
  153. package/vendor/l5x/studio-bridge/StudioBridge.csproj +11 -0
  154. package/vendor/mwi/README.md +60 -0
  155. package/vendor/mwi/SKILL.md +87 -0
  156. package/vendor/mwi/bridge/README.md +8 -0
  157. package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -0
  158. package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -0
  159. package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -0
  160. package/vendor/mwi/bridge/start_bridge.cmd +21 -0
  161. package/vendor/mwi/bridge/watch_dialogs.cmd +13 -0
  162. package/vendor/mwi/bridge/watch_dialogs.ps1 +153 -0
  163. package/vendor/mwi/code/engine/motionworks_iec_mcp/__init__.py +14 -0
  164. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
  165. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
  166. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
  167. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
  168. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
  169. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
  170. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
  171. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
  172. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
  173. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
  174. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
  175. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
  176. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
  177. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
  178. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
  179. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
  180. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
  181. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
  182. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
  183. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
  184. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
  185. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
  186. package/vendor/mwi/code/engine/motionworks_iec_mcp/block_interfaces.py +275 -0
  187. package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -0
  188. package/vendor/mwi/code/engine/motionworks_iec_mcp/capture.py +448 -0
  189. package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -0
  190. package/vendor/mwi/code/engine/motionworks_iec_mcp/com.py +268 -0
  191. package/vendor/mwi/code/engine/motionworks_iec_mcp/datatypes.py +204 -0
  192. package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -0
  193. package/vendor/mwi/code/engine/motionworks_iec_mcp/descriptions.py +30 -0
  194. package/vendor/mwi/code/engine/motionworks_iec_mcp/eip.py +292 -0
  195. package/vendor/mwi/code/engine/motionworks_iec_mcp/errors.py +36 -0
  196. package/vendor/mwi/code/engine/motionworks_iec_mcp/graphical_listing.py +208 -0
  197. package/vendor/mwi/code/engine/motionworks_iec_mcp/grid.py +227 -0
  198. package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +317 -0
  199. package/vendor/mwi/code/engine/motionworks_iec_mcp/iec.py +317 -0
  200. package/vendor/mwi/code/engine/motionworks_iec_mcp/installed_help.py +151 -0
  201. package/vendor/mwi/code/engine/motionworks_iec_mcp/knowledge.py +218 -0
  202. package/vendor/mwi/code/engine/motionworks_iec_mcp/libraries.py +250 -0
  203. package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -0
  204. package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -0
  205. package/vendor/mwi/code/engine/motionworks_iec_mcp/navigation.py +44 -0
  206. package/vendor/mwi/code/engine/motionworks_iec_mcp/pou_writer.py +1547 -0
  207. package/vendor/mwi/code/engine/motionworks_iec_mcp/program_checks.py +360 -0
  208. package/vendor/mwi/code/engine/motionworks_iec_mcp/project.py +704 -0
  209. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -0
  210. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -0
  211. package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -0
  212. package/vendor/mwi/code/engine/motionworks_iec_mcp/server.py +1913 -0
  213. package/vendor/mwi/code/engine/motionworks_iec_mcp/snapshot.py +138 -0
  214. package/vendor/mwi/code/engine/motionworks_iec_mcp/st_comments.py +25 -0
  215. package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +179 -0
  216. package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +676 -0
  217. package/vendor/mwi/code/engine/motionworks_iec_mcp/structure.py +98 -0
  218. package/vendor/mwi/code/engine/motionworks_iec_mcp/task_bindings.py +68 -0
  219. package/vendor/mwi/code/engine/motionworks_iec_mcp/tasks.py +162 -0
  220. package/vendor/mwi/code/engine/motionworks_iec_mcp/transaction.py +157 -0
  221. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree.py +563 -0
  222. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -0
  223. package/vendor/mwi/code/engine/motionworks_iec_mcp/uia.py +465 -0
  224. package/vendor/mwi/code/engine/motionworks_iec_mcp/validation.py +41 -0
  225. package/vendor/mwi/code/engine/motionworks_iec_mcp/variable_edit.py +183 -0
  226. package/vendor/mwi/code/engine/motionworks_iec_mcp/variables.py +472 -0
  227. package/vendor/mwi/code/engine/motionworks_iec_mcp/workflow.py +130 -0
  228. package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +666 -0
  229. package/vendor/mwi/code/mw_code.py +1258 -0
  230. package/vendor/mwi/legacy/edit-session.js +50 -0
  231. package/vendor/mwi/legacy/fb-insertion.js +74 -0
  232. package/vendor/mwi/legacy/native-code.js +83 -0
  233. package/vendor/mwi/legacy/native-groups.js +75 -0
  234. package/vendor/mwi/legacy/native-structure.js +143 -0
  235. package/vendor/mwi/legacy/native-variables.js +77 -0
  236. package/vendor/mwi/legacy/pou-conversion.js +47 -0
  237. package/vendor/mwi/legacy/pou-package.js +66 -0
  238. package/vendor/mwi/lib/core/bridge.d.ts +71 -0
  239. package/vendor/mwi/lib/core/bridge.js +53 -0
  240. package/vendor/mwi/lib/core/code-engine.d.ts +47 -0
  241. package/vendor/mwi/lib/core/code-engine.js +109 -0
  242. package/vendor/mwi/lib/core/errors.d.ts +37 -0
  243. package/vendor/mwi/lib/core/errors.js +47 -0
  244. package/vendor/mwi/lib/core/params.d.ts +52 -0
  245. package/vendor/mwi/lib/core/params.js +46 -0
  246. package/vendor/mwi/lib/core/ps-bridge.d.ts +62 -0
  247. package/vendor/mwi/lib/core/ps-bridge.js +127 -0
  248. package/vendor/mwi/lib/core/result.d.ts +45 -0
  249. package/vendor/mwi/lib/core/result.js +20 -0
  250. package/vendor/mwi/lib/core/tool.d.ts +80 -0
  251. package/vendor/mwi/lib/core/tool.js +55 -0
  252. package/vendor/mwi/lib/edit/compare.d.ts +33 -0
  253. package/vendor/mwi/lib/edit/compare.js +70 -0
  254. package/vendor/mwi/lib/edit.d.ts +20 -0
  255. package/vendor/mwi/lib/edit.js +32 -0
  256. package/vendor/mwi/lib/index.d.ts +21 -0
  257. package/vendor/mwi/lib/index.js +33 -0
  258. package/vendor/mwi/lib/read/service.d.ts +117 -0
  259. package/vendor/mwi/lib/read/service.js +229 -0
  260. package/vendor/mwi/lib/read.d.ts +21 -0
  261. package/vendor/mwi/lib/read.js +33 -0
  262. package/vendor/mwi/lib/session/project.d.ts +34 -0
  263. package/vendor/mwi/lib/session/project.js +54 -0
  264. package/vendor/mwi/lib/session/service.d.ts +167 -0
  265. package/vendor/mwi/lib/session/service.js +566 -0
  266. package/vendor/mwi/lib/session/stage.d.ts +84 -0
  267. package/vendor/mwi/lib/session/stage.js +187 -0
  268. package/vendor/mwi/lib/session/workspace.d.ts +52 -0
  269. package/vendor/mwi/lib/session/workspace.js +90 -0
  270. package/vendor/mwi/lib/skill.d.ts +36 -0
  271. package/vendor/mwi/lib/skill.js +56 -0
  272. package/vendor/mwi/lib/tools/edit.d.ts +20 -0
  273. package/vendor/mwi/lib/tools/edit.js +286 -0
  274. package/vendor/mwi/lib/tools/read.d.ts +19 -0
  275. package/vendor/mwi/lib/tools/read.js +161 -0
  276. package/vendor/mwi/lib/tools/session.d.ts +20 -0
  277. package/vendor/mwi/lib/tools/session.js +166 -0
  278. package/vendor/mwi/lib/tools/verify.d.ts +19 -0
  279. package/vendor/mwi/lib/tools/verify.js +85 -0
  280. package/vendor/mwi/lib/verify.d.ts +17 -0
  281. package/vendor/mwi/lib/verify.js +29 -0
@@ -0,0 +1,2763 @@
1
+ <#
2
+ MotionWorks Use - COM bridge (32-bit Windows PowerShell).
3
+
4
+ WHY THIS PROCESS EXISTS
5
+ -----------------------
6
+ MotionWorks IEC 3 Pro registers an out-of-process COM automation server:
7
+ Ade.Application.550 -> LocalServer32 = <install>\Mwt.exe
8
+ It can ONLY be instantiated from a 32-bit client, and on this machine the
9
+ DSH agent sandbox blocks that activation (CO_E_SERVER_EXEC_FAILURE 0x80080005).
10
+ Launched from outside the sandbox, the same call succeeds and reports
11
+ Version 1.19 with a clean IsProjectOpen.
12
+
13
+ So the agent does not click the IDE at all: it asks this bridge, which talks to
14
+ the IDE through the IDE's own automation API. No synthetic input is involved,
15
+ which is why neither the sandbox nor wincomputer's DRY_RUN gate matters here.
16
+
17
+ PROTOCOL
18
+ --------
19
+ The agent writes bridge\req.json {"id":<n>,"verb":"...", ...}
20
+ The bridge writes bridge\res.json {"id":<n>,"ok":bool,"verb":...,"data":...,"error":...}
21
+ and deletes req.json. Matching on `id` prevents reading a stale answer.
22
+
23
+ SAFETY (non-negotiable)
24
+ -----------------------
25
+ * Never downloads to a controller. Never commands motion. No such verb exists.
26
+ * `open` only accepts a project that lives under -StageRoot (a copy under the
27
+ agent workspace), never the real project tree.
28
+ * Never calls Quit: the user's IDE is left exactly as it was found.
29
+ #>
30
+ param(
31
+ [string]$BridgeDir = $(if ($env:MW_BRIDGE_DIR) { $env:MW_BRIDGE_DIR } else { $PSScriptRoot }),
32
+ # Defaults to the plugin's OWN stage directory, so `open` can only ever touch a
33
+ # copy that lives beside this bridge. Never point this at a real project tree.
34
+ [string]$StageRoot = '',
35
+ [int]$PollMs = 250
36
+ )
37
+
38
+ $ErrorActionPreference = 'Continue'
39
+ if ([string]::IsNullOrWhiteSpace($StageRoot)) {
40
+ $StageRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\stage'))
41
+ }
42
+ $reqPath = Join-Path $BridgeDir 'req.json'
43
+ $resPath = Join-Path $BridgeDir 'res.json'
44
+ $logPath = Join-Path $BridgeDir 'bridge.log'
45
+
46
+ function Log([string]$m) {
47
+ $line = "{0:HH:mm:ss.fff} {1}" -f (Get-Date), $m
48
+ Add-Content -Path $logPath -Value $line -Encoding UTF8
49
+ }
50
+
51
+ # --- single instance --------------------------------------------------------
52
+ #
53
+ # TWO bridges polling one req.json is a race, and it bit hard: the plugin spawns a
54
+ # bridge when it believes none is running, but a bridge stuck inside a slow verb
55
+ # looks dead, so a second one starts. Both then poll the same file, one consumes a
56
+ # request and (being slow) never answers it, and the caller times out with its work
57
+ # already taken from the queue. Symptom: "timed out waiting for the bridge" with no
58
+ # matching request in the log at all.
59
+ #
60
+ # The lock records the owning pid. A live owner means this process must exit rather
61
+ # than compete; a dead owner leaves a stale lock that is simply taken over.
62
+ $lockPath = Join-Path $BridgeDir 'bridge.lock'
63
+ $lockOwner = 0
64
+ if (Test-Path $lockPath) {
65
+ try { $lockOwner = [int]((Get-Content -Path $lockPath -Raw -ErrorAction Stop).Trim()) } catch { $lockOwner = 0 }
66
+ }
67
+ if ($lockOwner -gt 0) {
68
+ $alive = $null -ne (Get-Process -Id $lockOwner -ErrorAction SilentlyContinue)
69
+ if ($alive) {
70
+ Log "another bridge is already running (pid $lockOwner); exiting instead of competing for the queue"
71
+ exit 0
72
+ }
73
+ Log "stale bridge lock from pid $lockOwner; taking over"
74
+ }
75
+ Set-Content -Path $lockPath -Value $PID -Encoding ASCII
76
+
77
+ function Remove-BridgeLock {
78
+ try {
79
+ if (Test-Path $lockPath) {
80
+ $cur = (Get-Content -Path $lockPath -Raw -ErrorAction SilentlyContinue)
81
+ if ($null -ne $cur -and $cur.Trim() -eq "$PID") { Remove-Item -Path $lockPath -Force -ErrorAction SilentlyContinue }
82
+ }
83
+ } catch { }
84
+ }
85
+
86
+ function Write-Result($id, [string]$verb, $ok, $data, [string]$err) {
87
+ $payload = [ordered]@{
88
+ id = $id
89
+ verb = $verb
90
+ ok = [bool]$ok
91
+ data = $data
92
+ error = $err
93
+ pid = $PID
94
+ bits = ([IntPtr]::Size * 8)
95
+ ts = (Get-Date).ToString('o')
96
+ }
97
+ # Write to a temp name then move, so a reader never sees a half-written file.
98
+ #
99
+ # `Set-Content -Encoding UTF8` is NOT used: in Windows PowerShell 5.1 that
100
+ # means UTF-8 WITH a BOM, and JSON.parse in the Node half throws on the
101
+ # leading U+FEFF. The bridge would answer in 6ms and the plugin would still
102
+ # time out - a very expensive bug to find. WriteAllText with an explicit
103
+ # no-BOM UTF8Encoding keeps the bytes clean for a JS reader.
104
+ $tmp = "$resPath.tmp"
105
+ $json = $payload | ConvertTo-Json -Depth 8
106
+ [IO.File]::WriteAllText($tmp, $json, (New-Object Text.UTF8Encoding($false)))
107
+ Move-Item -Force $tmp $resPath
108
+ }
109
+
110
+ # --- IDE presence guard: only ever touch an IDE that is ALREADY running. ---
111
+ Add-Type -TypeDefinition @'
112
+ using System; using System.Text; using System.Runtime.InteropServices;
113
+ public class MWW {
114
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr l);
115
+ [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr parent, EnumWindowsProc cb, IntPtr l);
116
+ public delegate bool EnumWindowsProc(IntPtr h, IntPtr l);
117
+ [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, StringBuilder s, int n);
118
+ [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetClassNameW(IntPtr h, StringBuilder s, int n);
119
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
120
+ [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
121
+ [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
122
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
123
+
124
+ // Licence-dialog dismissal helpers. The unlicensed build shows a modal
125
+ // WindowsForms dialog before the IDE has any window of its own.
126
+ [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);
127
+ [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l);
128
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
129
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
130
+ [DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr h, uint flags);
131
+ [StructLayout(LayoutKind.Sequential)] public struct GUITHREADINFO {
132
+ public uint cbSize, flags;
133
+ public IntPtr hwndActive, hwndFocus, hwndCapture, hwndMenuOwner, hwndMoveSize, hwndCaret;
134
+ public RECT rcCaret;
135
+ }
136
+ [DllImport("user32.dll")] public static extern bool GetGUIThreadInfo(uint thread, ref GUITHREADINFO info);
137
+ public static IntPtr[] CaptureFocus() {
138
+ IntPtr foreground = GetForegroundWindow(); uint process;
139
+ uint thread = GetWindowThreadProcessId(foreground, out process);
140
+ GUITHREADINFO info = new GUITHREADINFO(); info.cbSize = (uint)Marshal.SizeOf(typeof(GUITHREADINFO));
141
+ if (foreground == IntPtr.Zero || thread == 0 || !GetGUIThreadInfo(thread, ref info))
142
+ throw new InvalidOperationException("Foreground/focus identity unavailable; capture unverified");
143
+ return new IntPtr[] { foreground, info.hwndActive, info.hwndFocus };
144
+ }
145
+ [DllImport("user32.dll")] public static extern IntPtr SetFocus(IntPtr h);
146
+ [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
147
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
148
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr LoadLibraryExW(string path, IntPtr file, uint flags);
149
+ [DllImport("kernel32.dll")] public static extern bool FreeLibrary(IntPtr module);
150
+ [DllImport("user32.dll")] public static extern IntPtr LoadMenuW(IntPtr module, IntPtr name);
151
+ [DllImport("user32.dll")] public static extern bool DestroyMenu(IntPtr menu);
152
+ [DllImport("user32.dll")] public static extern IntPtr GetSubMenu(IntPtr menu, int position);
153
+ [DllImport("user32.dll")] public static extern int GetMenuItemCount(IntPtr menu);
154
+ [DllImport("user32.dll")] public static extern uint GetMenuItemID(IntPtr menu, int position);
155
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetMenuStringW(IntPtr menu, uint position, StringBuilder text, int size, uint flags);
156
+ public static bool HasMessageWindowCommand(string executable) {
157
+ // Read resource data only: never execute another module or guess a command.
158
+ IntPtr module = LoadLibraryExW(executable, IntPtr.Zero, 2);
159
+ if (module == IntPtr.Zero) return false;
160
+ IntPtr menu = IntPtr.Zero;
161
+ try {
162
+ menu = LoadMenuW(module, new IntPtr(501));
163
+ if (menu == IntPtr.Zero) return false;
164
+ int matches = 0;
165
+ for (int i=0; i<GetMenuItemCount(menu); i++) {
166
+ var label = new StringBuilder(512);
167
+ GetMenuStringW(menu, (uint)i, label, 512, 0x400);
168
+ if (label.ToString().Replace("&", "").Split('\t')[0] != "View") continue;
169
+ IntPtr sub = GetSubMenu(menu, i);
170
+ for (int j=0; j<GetMenuItemCount(sub); j++) {
171
+ label.Clear(); GetMenuStringW(sub, (uint)j, label, 512, 0x400);
172
+ if (label.ToString().Replace("&", "").Split('\t')[0] == "Message Window") {
173
+ if (GetMenuItemID(sub, j) != 36554) return false;
174
+ matches++;
175
+ }
176
+ }
177
+ }
178
+ return matches == 1;
179
+ } finally { if (menu != IntPtr.Zero) DestroyMenu(menu); FreeLibrary(module); }
180
+ }
181
+ [DllImport("user32.dll")] public static extern IntPtr SetActiveWindow(IntPtr h);
182
+ [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
183
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint p);
184
+ [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
185
+
186
+ // Capture a window's OWN content into a DC, even when it is occluded.
187
+ // CopyFromScreen grabs a screen REGION, so a window stacked over the IDE is
188
+ // what you actually get in the PNG - which is how a screenshot of the IDE
189
+ // came back showing an unrelated window. PW_RENDERFULLCONTENT (0x2) asks the
190
+ // window to render itself instead.
191
+ [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint nFlags);
192
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
193
+ [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X,Y; }
194
+ [StructLayout(LayoutKind.Sequential)] public struct CURSORINFO {
195
+ public uint cbSize, flags; public IntPtr hCursor; public POINT ptScreenPos;
196
+ }
197
+ [StructLayout(LayoutKind.Sequential)] public struct ICONINFO {
198
+ [MarshalAs(UnmanagedType.Bool)] public bool fIcon;
199
+ public uint xHotspot,yHotspot; public IntPtr hbmMask,hbmColor;
200
+ }
201
+ [DllImport("user32.dll")] public static extern bool GetCursorInfo(ref CURSORINFO info);
202
+ [DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(POINT point);
203
+ [DllImport("user32.dll")] public static extern IntPtr CopyIcon(IntPtr icon);
204
+ [DllImport("user32.dll")] public static extern bool GetIconInfo(IntPtr icon,out ICONINFO info);
205
+ [DllImport("user32.dll")] public static extern bool DestroyIcon(IntPtr icon);
206
+ [DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr obj);
207
+ [StructLayout(LayoutKind.Sequential)] public struct BITMAP {
208
+ public int type,width,height,widthBytes; public ushort planes,bitsPixel; public IntPtr bits;
209
+ }
210
+ [DllImport("gdi32.dll")] public static extern int GetObject(IntPtr obj,int size,out BITMAP bitmap);
211
+ [DllImport("user32.dll")] public static extern bool DrawIconEx(IntPtr dc,int x,int y,IntPtr icon,int width,int height,uint step,IntPtr brush,uint flags);
212
+ [DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
213
+ public static long[] CaptureCursor() {
214
+ CURSORINFO info=new CURSORINFO(); info.cbSize=(uint)Marshal.SizeOf(typeof(CURSORINFO));
215
+ if(!GetCursorInfo(ref info)) throw new InvalidOperationException("Cursor observation unavailable");
216
+ IntPtr owner=GetAncestor(WindowFromPoint(info.ptScreenPos),3);
217
+ return new long[]{info.flags,info.hCursor.ToInt64(),info.ptScreenPos.X,info.ptScreenPos.Y,owner.ToInt64()};
218
+ }
219
+ public static int[] DrawCapturedCursor(IntPtr dc,long[] cursor,int left,int top) {
220
+ IntPtr copy=CopyIcon(new IntPtr(cursor[1]));
221
+ if(copy==IntPtr.Zero) throw new InvalidOperationException("Cursor image copy failed");
222
+ ICONINFO info=new ICONINFO();
223
+ try {
224
+ if(!GetIconInfo(copy,out info)) throw new InvalidOperationException("Cursor hotspot unavailable");
225
+ BITMAP bitmap; IntPtr image=info.hbmColor!=IntPtr.Zero?info.hbmColor:info.hbmMask;
226
+ if(GetObject(image,Marshal.SizeOf(typeof(BITMAP)),out bitmap)==0) throw new InvalidOperationException("Cursor dimensions unavailable");
227
+ int width=bitmap.width,height=info.hbmColor!=IntPtr.Zero?bitmap.height:bitmap.height/2;
228
+ if(width<=0||height<=0) throw new InvalidOperationException("Cursor dimensions invalid");
229
+ int x=(int)cursor[2]-left-(int)info.xHotspot,y=(int)cursor[3]-top-(int)info.yHotspot;
230
+ if(!DrawIconEx(dc,x,y,copy,width,height,0,IntPtr.Zero,3)) throw new InvalidOperationException("Cursor rendering failed");
231
+ return new int[]{(int)info.xHotspot,(int)info.yHotspot,width,height};
232
+ } finally {
233
+ if(info.hbmMask!=IntPtr.Zero) DeleteObject(info.hbmMask);
234
+ if(info.hbmColor!=IntPtr.Zero) DeleteObject(info.hbmColor);
235
+ DestroyIcon(copy);
236
+ }
237
+ }
238
+
239
+ // Read the Message Window's lists as TEXT.
240
+ //
241
+ // The compiler's messages live in a SysListView32, and a screenshot is useless to
242
+ // an agent that has to act on them - it cannot turn a PNG into a fix. MSAA exposes
243
+ // the rows through IAccessible and accName(i) returns each row verbatim, so the
244
+ // exact compiler text comes back. AccessibleChildren with an [Out] object[] fails
245
+ // from PowerShell with E_INVALIDARG (0x80070057), so children are read by index.
246
+ [DllImport("oleacc.dll")] public static extern int AccessibleObjectFromWindow(IntPtr hwnd, uint id, ref Guid iid, [MarshalAs(UnmanagedType.IUnknown)] out object ppv);
247
+ [DllImport("user32.dll", EntryPoint="SendMessageW")] public static extern IntPtr SendMessagePtr(IntPtr h, uint m, IntPtr w, IntPtr l);
248
+
249
+ public static object GetAccessible(IntPtr hwnd) {
250
+ try {
251
+ Guid iid = new Guid("618736E0-3C3D-11CF-810C-00AA00389B71"); // IAccessible
252
+ object acc;
253
+ int hr = AccessibleObjectFromWindow(hwnd, 0xFFFFFFFC, ref iid, out acc);
254
+ return hr == 0 ? acc : null;
255
+ } catch { return null; }
256
+ }
257
+ // LVM_GETITEMCOUNT = 0x1004
258
+ public static int ListRowCount(IntPtr h) { return (int)SendMessagePtr(h, 0x1004, IntPtr.Zero, IntPtr.Zero); }
259
+
260
+ // Modal-dialog inspection. MotionWorks asks questions in standard Win32 #32770
261
+ // dialogs, whose text and buttons are readable through WM_GETTEXT/GetDlgCtrlID.
262
+ // While one is up the frame window is DISABLED, and the COM API simply reports
263
+ // nothing - which is how an agent concludes "no project" or "IDE closed" when
264
+ // the IDE is really waiting for a button press.
265
+ [DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr h);
266
+ [DllImport("user32.dll")] public static extern int GetDlgCtrlID(IntPtr h);
267
+ [DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr h);
268
+ [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr h, uint cmd);
269
+ [DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)]
270
+ public static extern IntPtr SendStr(IntPtr h, uint msg, IntPtr wp, StringBuilder lp);
271
+ [DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)]
272
+ public static extern IntPtr SendInt(IntPtr h, uint msg, IntPtr wp, IntPtr lp);
273
+
274
+ // Read a control's text across process boundaries. GetWindowTextW returns
275
+ // nothing for another process's controls; WM_GETTEXT does not have that limit.
276
+ //
277
+ // SendMessageTimeout, NOT SendMessage: SendMessage is synchronous and blocks
278
+ // FOREVER if the target is not pumping messages - which is exactly the state a
279
+ // modal MotionWorks dialog leaves it in. That hung the whole bridge for five
280
+ // minutes on a start_ide call, because the failure path inspects dialogs.
281
+ // SMTO_ABORTIFHUNG (0x2) plus a short timeout makes inspection always safe.
282
+ [DllImport("user32.dll", SetLastError=true, EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode)]
283
+ public static extern IntPtr SendTimeoutStr(IntPtr h, uint msg, IntPtr wp, StringBuilder lp, uint flags, uint ms, out IntPtr result);
284
+ [DllImport("user32.dll", SetLastError=true, EntryPoint="SendMessageTimeoutW", CharSet=CharSet.Unicode)]
285
+ public static extern IntPtr SendTimeoutInt(IntPtr h, uint msg, IntPtr wp, IntPtr lp, uint flags, uint ms, out IntPtr result);
286
+
287
+ const uint SMTO_ABORTIFHUNG = 0x0002;
288
+ const uint TEXT_TIMEOUT_MS = 200;
289
+
290
+ public static string ReadText(IntPtr h) { return ReadTextMs(h, TEXT_TIMEOUT_MS); }
291
+
292
+ public static string ReadTextMs(IntPtr h, uint ms) {
293
+ IntPtr result;
294
+ IntPtr sent = SendTimeoutInt(h, 0x000E, IntPtr.Zero, IntPtr.Zero, SMTO_ABORTIFHUNG, ms, out result); // WM_GETTEXTLENGTH
295
+ if (sent == IntPtr.Zero) return ""; // hung or gone: no text
296
+ int n = result.ToInt32();
297
+ if (n <= 0) return "";
298
+ if (n > 65536) n = 65536;
299
+ StringBuilder sb = new StringBuilder(n + 2);
300
+ sent = SendTimeoutStr(h, 0x000D, (IntPtr)(n + 1), sb, SMTO_ABORTIFHUNG, ms, out result); // WM_GETTEXT
301
+ if (sent == IntPtr.Zero) return "";
302
+ return sb.ToString();
303
+ }
304
+
305
+ // Press a button without risking a permanent block. BM_CLICK is synchronous
306
+ // like any SendMessage, so it is sent with a timeout and allowed to fail.
307
+ public static bool ClickButton(IntPtr h) {
308
+ IntPtr result;
309
+ IntPtr sent = SendTimeoutInt(h, 0x00F5, IntPtr.Zero, IntPtr.Zero, SMTO_ABORTIFHUNG, CLICK_TIMEOUT_MS, out result);
310
+ if (sent != IntPtr.Zero) return true;
311
+ // A hung dialog will not take BM_CLICK; posting is asynchronous and at least
312
+ // queues the click for whenever the dialog next pumps.
313
+ return PostMessage(h, 0x00F5, IntPtr.Zero, IntPtr.Zero);
314
+ }
315
+
316
+ const uint CLICK_TIMEOUT_MS = 2500;
317
+ }
318
+ '@
319
+
320
+ function Get-IdeWindow {
321
+ # NOTE: the callback runs in a child scope, so the result must be stashed in
322
+ # the SCRIPT scope. Assigning to a function-local inside the delegate and
323
+ # returning it here silently always returns $null - that bug made the bridge
324
+ # report "no running IDE" while the IDE was plainly on screen.
325
+ #
326
+ # Match on a PREFIX, not equality: once a project is open the caption becomes
327
+ # "MotionWorks IEC 3 Pro - <project>", so an exact match stops finding the IDE
328
+ # at the exact moment the IDE becomes useful.
329
+ #
330
+ # AND accept "MULTIPROG - <project>": MotionWorks IEC is an OEM build of
331
+ # MULTIPROG, and when Mwt.exe is launched directly (which is exactly what
332
+ # automation does) it titles itself MULTIPROG, not "MotionWorks IEC 3 Pro".
333
+ # Matching only the latter made the bridge blind to an IDE it had just started.
334
+ #
335
+ # Then pick the LARGEST match. Startup creates small auxiliary windows that also
336
+ # carry these captions, and returning one of those produced a 426x166 "IDE
337
+ # screenshot" instead of the 1936x1048 frame.
338
+ $script:cands = @()
339
+ $cb = [MWW+EnumWindowsProc]{
340
+ param($h, $l)
341
+ if ([MWW]::IsWindowVisible($h)) {
342
+ # ReadText, not GetWindowTextW. The latter sends WM_GETTEXT and waits
343
+ # forever on a window that is not pumping, which made the first
344
+ # ide_state of a session time out while some other app was hung.
345
+ $t = [MWW]::ReadTextMs($h, 50)
346
+ if ($t -like 'MotionWorks IEC 3 Pro*' -or $t -like 'MULTIPROG*') {
347
+ $rc = New-Object MWW+RECT
348
+ $area = 0
349
+ if ([MWW]::GetWindowRect($h, [ref]$rc)) {
350
+ $area = [int64]($rc.Right - $rc.Left) * [int64]($rc.Bottom - $rc.Top)
351
+ }
352
+ $script:cands += [pscustomobject]@{ h = $h; area = $area; title = $t }
353
+ }
354
+ }
355
+ return $true
356
+ }
357
+ [void][MWW]::EnumWindows($cb, [IntPtr]::Zero)
358
+ if ($script:cands.Count -eq 0) { return $null }
359
+ return ($script:cands | Sort-Object -Property area -Descending | Select-Object -First 1).h
360
+ }
361
+
362
+ # --- licence / trial dialog -------------------------------------------------
363
+ #
364
+ # The UNLICENSED build shows a modal licence dialog BEFORE the IDE creates any
365
+ # window of its own. It is a .NET WinForms window owned by mwctVerify.exe, titled
366
+ # "Motionworks IEC" (lower-case w), with children "Use Trial", "Activate Online",
367
+ # "Activate by Phone", "Find Distributor".
368
+ #
369
+ # Two consequences the bridge must handle:
370
+ # 1. Get-IdeWindow does NOT see it (wrong owner, wrong class, no "3 Pro"), so a
371
+ # launcher that only waits for the IDE window sits there until it times out
372
+ # while a dialog is actually waiting for a human click.
373
+ # 2. Until it is answered, the IDE has no project services and OpenProject fails
374
+ # with "Internal error in 'OpenProject'".
375
+ function Get-TrialDialog {
376
+ $script:trialDlg = $null
377
+ $script:trialBtn = $null
378
+ $script:trialCandidates = @()
379
+ $cb = [MWW+EnumWindowsProc]{
380
+ param($h, $l)
381
+ if ([MWW]::IsWindowVisible($h)) {
382
+ $ownerPid = [uint32]0
383
+ [void][MWW]::GetWindowThreadProcessId($h, [ref]$ownerPid)
384
+ $owner = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
385
+ if (-not $owner -or $owner.ProcessName -ine 'mwctVerify') { return $true }
386
+ $cls = New-Object System.Text.StringBuilder 256
387
+ [void][MWW]::GetClassNameW($h, $cls, 256)
388
+ if ($cls.ToString() -like 'WindowsForms10.Window*') {
389
+ $script:candidateTrialButton = $null
390
+ $cb2 = [MWW+EnumWindowsProc]{
391
+ param($k, $l2)
392
+ if (([MWW]::ReadText($k) -replace '&','').Trim() -ieq 'Use Trial' -and [MWW]::IsWindowVisible($k)) {
393
+ $script:candidateTrialButton = $k
394
+ }
395
+ return $true
396
+ }
397
+ [void][MWW]::EnumChildWindows($h, $cb2, [IntPtr]::Zero)
398
+ if ($script:candidateTrialButton) {
399
+ $script:trialCandidates += [pscustomobject]@{ dialog = $h; button = $script:candidateTrialButton }
400
+ }
401
+ }
402
+ }
403
+ return $true
404
+ }
405
+ [void][MWW]::EnumWindows($cb, [IntPtr]::Zero)
406
+ if ($script:trialCandidates.Count -gt 1) { throw 'Multiple MotionWorks trial dialogs found; refusing an ambiguous action' }
407
+ if ($script:trialCandidates.Count -eq 1) {
408
+ $script:trialDlg = $script:trialCandidates[0].dialog
409
+ $script:trialBtn = $script:trialCandidates[0].button
410
+ }
411
+ return @($script:trialDlg, $script:trialBtn)
412
+ }
413
+
414
+ function Answer-TrialDialog {
415
+ $pair = Get-TrialDialog
416
+ $dialog = $pair[0]; $button = $pair[1]
417
+ if (-not $dialog) { return [ordered]@{ dialog_present = $false; dismissed = $true; method = 'none-needed'; seconds = 0; strategies_tried = @() } }
418
+ if (-not [MWW]::IsWindowEnabled($button)) { throw 'Use Trial is disabled; no licence action was submitted' }
419
+ # Prefer the control's semantic Invoke API. Only fall back if Invoke is not
420
+ # available BEFORE input; an ambiguous submitted action is never retried.
421
+ $pattern = $null
422
+ try {
423
+ Add-Type -AssemblyName UIAutomationClient
424
+ Add-Type -AssemblyName UIAutomationTypes
425
+ $element = [System.Windows.Automation.AutomationElement]::FromHandle($button)
426
+ [void]$element.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$pattern)
427
+ } catch { $pattern = $null }
428
+ if ($pattern) {
429
+ $pattern.Invoke()
430
+ $method = 'uia_invoke'
431
+ } else {
432
+ Invoke-TrialClick $button $dialog {
433
+ if (-not [MWW]::PostMessage($button, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero)) {
434
+ throw 'Use Trial native button command was refused'
435
+ }
436
+ }
437
+ $method = 'posted_bm_click'
438
+ }
439
+ $seconds = Test-TrialGone $dialog 10
440
+ $gone = -not [MWW]::IsWindow($dialog) -or -not [MWW]::IsWindowVisible($dialog)
441
+ return [ordered]@{ dialog_present = $true; dismissed = $gone; method = $method; seconds = $seconds; strategies_tried = @($method) }
442
+ }
443
+
444
+ function Invoke-TrialClick([IntPtr]$target, [IntPtr]$dialog, [scriptblock]$action) {
445
+ # Attach our input thread to the dialog's threads and foreground it first.
446
+ # Without this the synthetic click is ignored: measured, BM_CLICK and the
447
+ # mouse messages are accepted and do nothing while the dialog is not active.
448
+ $pidT = [uint32]0; $tid = [MWW]::GetWindowThreadProcessId($target, [ref]$pidT)
449
+ $pidD = [uint32]0; $tidD = [MWW]::GetWindowThreadProcessId($dialog, [ref]$pidD)
450
+ $me = [MWW]::GetCurrentThreadId()
451
+ if (-not $action) { throw 'Trial fallback requires one explicit button action' }
452
+ $attached = @()
453
+ try {
454
+ # Button and dialog normally share one thread. Attach it only once and
455
+ # detach only successful attachments, even if activation or posting fails.
456
+ foreach ($thread in @($tid, $tidD) | Select-Object -Unique) {
457
+ if ($thread -and $thread -ne $me) {
458
+ try {
459
+ if ([MWW]::AttachThreadInput($me, $thread, $true)) { $attached += $thread }
460
+ } catch { }
461
+ }
462
+ }
463
+ [void][MWW]::ShowWindow($dialog, 5)
464
+ [void][MWW]::BringWindowToTop($dialog)
465
+ [void][MWW]::SetForegroundWindow($dialog)
466
+ [void][MWW]::SetActiveWindow($dialog)
467
+ [void][MWW]::SetFocus($target)
468
+ Start-Sleep -Milliseconds 700
469
+ & $action
470
+ } finally {
471
+ $detachFailures = @()
472
+ for ($index = $attached.Count - 1; $index -ge 0; $index--) {
473
+ try {
474
+ if (-not [MWW]::AttachThreadInput($me, $attached[$index], $false)) { $detachFailures += $attached[$index] }
475
+ } catch { $detachFailures += $attached[$index] }
476
+ }
477
+ if ($detachFailures.Count) {
478
+ throw "Trial fallback input-thread cleanup failed for $($detachFailures -join ','). The button action may already have been submitted; inspect trial state before further input. No automatic retry was sent."
479
+ }
480
+ }
481
+ }
482
+
483
+ function Test-TrialGone([IntPtr]$dialog, [int]$seconds = 20) {
484
+ for ($i = 0; $i -lt ($seconds / 2); $i++) {
485
+ Start-Sleep -Seconds 2
486
+ if (-not [MWW]::IsWindow($dialog) -or -not [MWW]::IsWindowVisible($dialog)) { return [int](($i * 2) + 2) }
487
+ }
488
+ return 0
489
+ }
490
+
491
+ # --- modal dialogs: the state the COM API cannot see ------------------------
492
+ #
493
+ # MotionWorks asks its questions in standard Win32 #32770 dialogs. While one is
494
+ # up the frame window is DISABLED and the automation API simply goes quiet:
495
+ # IsProjectOpen() reports false or throws, OpenProject fails, and an agent that
496
+ # trusts COM alone concludes "no project open" or "the IDE has closed" while the
497
+ # IDE is really sitting there waiting for a button press.
498
+ #
499
+ # These are plain Win32 dialogs, so their text and buttons are EXACTLY readable
500
+ # through WM_GETTEXT + GetDlgCtrlID - no OCR approximation needed. The screenshot
501
+ # remains the backstop for owner-drawn or foreign dialogs (the .NET licence
502
+ # dialog), where text extraction returns nothing.
503
+ $script:dialogHwnds = @{}
504
+
505
+ function Get-IdePid {
506
+ $w = Get-IdeWindow
507
+ if (-not $w) { return 0 }
508
+ $p = [uint32]0
509
+ [void][MWW]::GetWindowThreadProcessId([IntPtr]$w, [ref]$p)
510
+ return [int]$p
511
+ }
512
+
513
+ function Get-IdeUiStatus {
514
+ $ideProcessId = Get-IdePid
515
+ try {
516
+ if (-not $ideProcessId) { throw 'No native IDE frame' }
517
+ $process = Get-Process -Id $ideProcessId -ErrorAction Stop
518
+ return @{ pid = $ideProcessId; responding = [bool]$process.Responding; caption = [string]$process.MainWindowTitle }
519
+ } catch {
520
+ return @{ pid = $ideProcessId; responding = $null; caption = $null }
521
+ }
522
+ }
523
+
524
+ function Get-WorksheetEditorState($requested, $actual, $beforeModified, $afterModified, $ui, $expectedCaption) {
525
+ $nativeVerified = ($actual -ceq $requested -and $beforeModified -is [bool] -and
526
+ $afterModified -is [bool] -and $beforeModified -eq $afterModified)
527
+ $captionMatches = $null
528
+ if (-not [string]::IsNullOrWhiteSpace([string]$ui.caption)) {
529
+ $captionMatches = ([string]$ui.caption).EndsWith(('[' + $expectedCaption + ']'), [StringComparison]::OrdinalIgnoreCase)
530
+ }
531
+ $ready = ($nativeVerified -and $ui.responding -eq $true -and $captionMatches -eq $true)
532
+ return [ordered]@{ native_navigation_verified = [bool]$nativeVerified; ui_responsive = $ui.responding;
533
+ editor_caption_matches = $captionMatches; editor_caption = $ui.caption; editor_window_ready = [bool]$ready;
534
+ keyboard_focus_verified = $false }
535
+ }
536
+
537
+ # A document already open through a task instance may be reused by OpenDocument.
538
+ # Its active view is the instance, not /Pous/<type>/<sheet>. Resolve that exact
539
+ # native instance (adeOtProgramInstance=19); never accept a caption alone.
540
+ function Get-WorksheetInstanceView($app, [string]$actual, [string]$pouName, [string]$sheetName) {
541
+ if($actual -cnotmatch '^/Hardware/([^/.\\]+)/([^/.\\]+)/Tasks/([^/.\\]+)/([^/.\\]+)$'){return $null}
542
+ $config=$Matches[1];$resource=$Matches[2];$task=$Matches[3];$name=$Matches[4]
543
+ try {
544
+ $instance=$app.ActiveProject.GetObjectByLogicalName($actual.TrimStart('/'),19)
545
+ if($null -eq $instance -or [string]$instance.LogicalName -cne $actual -or
546
+ [string]$instance.Name -cne $name -or [string]$instance.Type -cne $pouName){return $null}
547
+ return [ordered]@{logical_name=$actual;instance_name=$name;pou=$pouName;task_name=$task;
548
+ expected_caption=('{0}:{1} - {2}.{3}.{4}.{5}.{0}' -f $sheetName,$pouName,$config,$resource,$task,$name)}
549
+ }catch{return $null}
550
+ }
551
+
552
+ function Get-IdeDialogs {
553
+ $idePid = Get-IdePid
554
+ $script:dialogHwnds = @{}
555
+ $found = New-Object System.Collections.ArrayList
556
+ if ($idePid -eq 0) { return $found }
557
+
558
+ $cb = [MWW+EnumWindowsProc]{
559
+ param($h, $l)
560
+ if (-not [MWW]::IsWindowVisible($h)) { return $true }
561
+ $p = [uint32]0; [void][MWW]::GetWindowThreadProcessId($h, [ref]$p)
562
+ if ([int]$p -ne $script:wantPid) { return $true }
563
+ $cls = New-Object System.Text.StringBuilder 64
564
+ [void][MWW]::GetClassNameW($h, $cls, 64)
565
+ if ($cls.ToString() -ne '#32770') { return $true }
566
+
567
+ $buttons = New-Object System.Collections.ArrayList
568
+ $texts = New-Object System.Collections.ArrayList
569
+ $title = [MWW]::ReadText($h)
570
+ $hex = "0x{0:X}" -f ([int64]$h)
571
+ $script:dialogHwnds[$hex] = $h
572
+ $script:curDlgHex = $hex
573
+
574
+ $cb2 = [MWW+EnumWindowsProc]{
575
+ param($k, $l2)
576
+ if (-not [MWW]::IsWindowVisible($k)) { return $true }
577
+ $c = New-Object System.Text.StringBuilder 64
578
+ [void][MWW]::GetClassNameW($k, $c, 64)
579
+ $cn = $c.ToString()
580
+ # Only these two classes carry the question and its answers, and the class
581
+ # is checked BEFORE reading text on purpose: every cross-process read can
582
+ # wait out its full timeout while the IDE is busy loading a project, so
583
+ # reading text for controls we do not care about made one inspection pass
584
+ # take seconds and hung the whole open call.
585
+ if ($cn -ne 'Button' -and $cn -ne 'Static') { return $true }
586
+ $txt = [MWW]::ReadText($k)
587
+ if (-not $txt) { return $true }
588
+ if ($cn -eq 'Button') {
589
+ $bid = [MWW]::GetDlgCtrlID($k)
590
+ # The handle is needed to press it, and an IntPtr is not JSON, so it
591
+ # travels in a side map keyed by dialog|id rather than in the result.
592
+ $script:dialogButtons["$script:curDlgHex|$bid"] = $k
593
+ [void]$buttons.Add([ordered]@{
594
+ id = $bid
595
+ label = ($txt -replace '&', '').Trim()
596
+ })
597
+ } elseif ($cn -eq 'Static' -and $txt.Trim()) {
598
+ [void]$texts.Add($txt.Trim())
599
+ }
600
+ return $true
601
+ }
602
+ [void][MWW]::EnumChildWindows($h, $cb2, [IntPtr]::Zero)
603
+
604
+ [void]$found.Add([ordered]@{
605
+ handle = $hex
606
+ title = $title
607
+ message = (($texts | Select-Object -Unique) -join "`n")
608
+ buttons = $buttons
609
+ enabled = [MWW]::IsWindowEnabled($h)
610
+ })
611
+ return $true
612
+ }
613
+ $script:wantPid = $idePid
614
+ $script:dialogButtons = @{}
615
+ [void][MWW]::EnumWindows($cb, [IntPtr]::Zero)
616
+ return $found
617
+ }
618
+
619
+ # One screen of truth about the IDE, suitable for a tool result.
620
+ function Get-IdeState {
621
+ $trial = Get-TrialDialog
622
+ $trialPresent = [bool]$trial[0]
623
+ $verifierRunning = [bool](Get-Process -Name mwctVerify -ErrorAction SilentlyContinue)
624
+ $w = Get-IdeWindow
625
+ $dialogs = Get-IdeDialogs
626
+ $blocked = $trialPresent
627
+ $enabled = $null
628
+ if ($w) {
629
+ $enabled = [MWW]::IsWindowEnabled([IntPtr]$w)
630
+ $blocked = $trialPresent -or (-not $enabled) -or ($dialogs.Count -gt 0)
631
+ }
632
+ $t = ''
633
+ if ($w) {
634
+ $t = [MWW]::ReadText([IntPtr]$w)
635
+ }
636
+ return [ordered]@{
637
+ ide_running = [bool]$w
638
+ ide_window = $(if ($w) { "0x{0:X}" -f ([int64]$w) } else { $null })
639
+ window_title = $t
640
+ ide_enabled = $enabled
641
+ blocked = $blocked
642
+ dialog_count = $dialogs.Count
643
+ dialogs = @($dialogs)
644
+ trial_dialog = $trialPresent
645
+ trial_dialog_hwnd = $(if ($trialPresent) { "0x{0:X}" -f ([int64]$trial[0]) } else { $null })
646
+ verifier_running = $verifierRunning
647
+ hint = $(if ($trialPresent) {
648
+ 'The MotionWorks trial verifier is waiting. Use mw_ide_trial(attempt:true) for one native Use Trial action and verified closure. Do not launch another IDE or use generic answer_dialog.'
649
+ } elseif ($blocked) {
650
+ 'A modal dialog is blocking the IDE. The automation API returns nothing while it is up, so "no project" / "IDE closed" MUST NOT be concluded. Answer it with answer_dialog.'
651
+ } else { $null })
652
+ }
653
+ }
654
+
655
+ # Prompts MotionWorks raises on open that have one obviously-safe answer. Each is
656
+ # a recoverable question about loading a COPY in the staging area, so stopping to
657
+ # ask a human for every one of them is what made opening a project look broken.
658
+ # Anything not in this table is left strictly alone and reported, so the agent
659
+ # decides rather than the bridge guessing.
660
+ $script:knownAnswers = @(
661
+ @{ match = 'defragment'; button = 'No' }
662
+ @{ match = 'would you like to load the project anyway'; button = 'Yes' }
663
+ @{ match = 'currently loaded by'; button = 'Yes' }
664
+ @{ match = 'abnormal termination'; button = 'Yes' }
665
+ @{ match = 'disabled/enabled by software key'; button = 'OK' }
666
+ )
667
+
668
+ function Resolve-KnownDialogs {
669
+ # ONE enumeration, answering everything that matches. An earlier version looped
670
+ # up to six passes internally, and each pass re-enumerated and re-read every
671
+ # dialog - so a single call could cost tens of seconds while the IDE was busy
672
+ # loading (every cross-process read waits out its timeout), which is what made
673
+ # an open call run for minutes. Callers repeat, so one pass is enough.
674
+ #
675
+ # Inspection is also globally budgeted: past $script:dialogBudget the IDE is
676
+ # plainly too busy to interrogate, and spending more of the call's deadline on
677
+ # it guarantees a timeout instead of an answer.
678
+ if ($null -eq $script:dialogSpent) { $script:dialogSpent = 0.0 }
679
+ if ($script:dialogSpent -gt 25.0) { return @() }
680
+ $swD = [Diagnostics.Stopwatch]::StartNew()
681
+ try {
682
+ $answered = New-Object System.Collections.ArrayList
683
+ $dialogs = @(Get-IdeDialogs)
684
+ if ($dialogs.Count -eq 0) { return $answered }
685
+ foreach ($d in $dialogs) {
686
+ $hay = ("$($d.title)`n$($d.message)").ToLower()
687
+ foreach ($k in $script:knownAnswers) {
688
+ if ($hay -notlike "*$($k.match)*") { continue }
689
+ $btn = $d.buttons | Where-Object { $_.label -ieq $k.button } | Select-Object -First 1
690
+ if ($btn) {
691
+ $bh = $script:dialogButtons["$($d.handle)|$($btn.id)"]
692
+ if ($bh -and [MWW]::ClickButton([IntPtr]$bh)) {
693
+ [void]$answered.Add([ordered]@{
694
+ dialog = $d.handle
695
+ matched = $k.match
696
+ pressed = $btn.label
697
+ message = $d.message
698
+ })
699
+ }
700
+ }
701
+ break
702
+ }
703
+ }
704
+ if ($answered.Count -gt 0) { Start-Sleep -Milliseconds 700 }
705
+ return $answered
706
+ } finally {
707
+ $swD.Stop()
708
+ $script:dialogSpent += $swD.Elapsed.TotalSeconds
709
+ }
710
+ }
711
+
712
+ # --- COM connection, held open across requests ---
713
+ $script:App = $null
714
+
715
+ function Connect-App {
716
+ if ($script:App -ne $null) {
717
+ # A cached connection can outlive the IDE it points at: the coding loop
718
+ # closes the IDE to write code, then starts it again. The dead proxy stays
719
+ # valid as an object, so without this probe every later call fails with
720
+ # "The RPC server is unavailable" (0x800706BA) until the bridge restarts.
721
+ #
722
+ # Require a REAL answer, not merely "did not throw": a half-dead proxy
723
+ # returns an empty Version and then fails the next call, which is exactly
724
+ # how a stale connection slipped through the earlier check.
725
+ try {
726
+ $probe = [string]$script:App.Version
727
+ if (-not [string]::IsNullOrWhiteSpace($probe)) { return $script:App }
728
+ Log "cached connection answered with an empty Version; dropping it"
729
+ } catch {
730
+ Log "cached connection failed its probe ($($_.Exception.Message)); dropping it"
731
+ }
732
+ $script:App = $null
733
+ }
734
+ if (-not (Get-IdeWindow)) {
735
+ # A freshly launched IDE has a live Mwt process before it has a top-level window,
736
+ # and the coding loop closes and restarts the IDE constantly. Polling while the
737
+ # process exists turns "start_ide then open" from a spurious refusal into a wait.
738
+ # When no Mwt process exists there is genuinely no IDE, and the refusal stands at
739
+ # once rather than making every request pay a timeout.
740
+ $deadline = (Get-Date).AddSeconds(20)
741
+ while ((Get-Date) -lt $deadline -and -not (Get-IdeWindow) -and (Get-Process -Name Mwt -ErrorAction SilentlyContinue)) {
742
+ Start-Sleep -Milliseconds 400
743
+ }
744
+ if (Get-IdeWindow) { Log 'IDE window appeared while waiting for a freshly started IDE' }
745
+ }
746
+ if (-not (Get-IdeWindow)) {
747
+ throw "no running MotionWorks IDE window; refusing to instantiate (that would launch a new IDE and a trial licence permits only one instance). Use the 'start_ide' verb to launch one deliberately."
748
+ }
749
+ $script:App = New-Object -ComObject Ade.Application.550
750
+ Log "connected: Version=$($script:App.Version)"
751
+ return $script:App
752
+ }
753
+
754
+ function Get-VariableSheet($app,[string]$pou) {
755
+ if ($pou) {
756
+ $matches=@()
757
+ for($i=1;$i -le $app.ActiveProject.Pous.Count;$i++) {
758
+ $p=$app.ActiveProject.Pous.Item($i)
759
+ if([string]$p.Name -ieq $pou){$matches+=,$p}
760
+ }
761
+ if($matches.Count -ne 1){throw 'REFUSED: exact POU not found'}
762
+ return ,($matches[0].Variables)
763
+ }
764
+ $resource=$app.ActiveProject.GetObjectByLogicalName('Hardware/Configuration/Resource',10)
765
+ if(-not $resource){throw 'REFUSED: exact resource not found'}
766
+ return ,($resource.Variables)
767
+ }
768
+ function Get-VariableSnapshot($app,[string]$pou,$requireSaved=$false) {
769
+ if($requireSaved -isnot [bool]){throw 'REFUSED: require_saved must be an explicit boolean'}
770
+ if($requireSaved -and $app.ActiveProject.IsModified -ne $false){throw 'REFUSED: reconcile/save native edits before snapshot'}
771
+ $vars=Get-VariableSheet $app $pou
772
+ $groups=@()
773
+ for($i=1;$i -le $vars.Groups.Count;$i++) {
774
+ $g=$vars.Groups.Item($i)
775
+ $groups+=@{name=[string]$g.Name;read_only=[bool]$g.ReadOnly}
776
+ }
777
+ $data=[ordered]@{variables=(Get-VariableRows $vars);groups=$groups}
778
+ if($requireSaved) {
779
+ if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: native saved state changed during snapshot'}
780
+ $data.is_modified=$false
781
+ }
782
+ return $data
783
+ }
784
+ function Initialize-SdkVariableReader {
785
+ if ($script:SdkReaderAttempted) { return }
786
+ $script:SdkReaderAttempted=$true
787
+ $script:SdkReaderEnabled=$false
788
+ if ($env:MW_DISABLE_SDK_READER -eq '1') {
789
+ Log 'SDK variable reader disabled by diagnostic override; using complete legacy reader'
790
+ return
791
+ }
792
+ try {
793
+ $sdk=Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Yaskawa\MotionWorks IEC 3 Pro\Ade.tlb'
794
+ # Only the exact SDK whose full native rows and signatures were verified.
795
+ $supported='7ccec37b8fcff5d1a55198f51a7f97ded0429b2eb0ab740b5cede8cfd9950c6e'
796
+ if (-not (Test-Path -LiteralPath $sdk) -or (Get-MwSha256 $sdk) -ine $supported) {
797
+ Log 'SDK variable reader unavailable: unverified SDK; using complete legacy reader'
798
+ return
799
+ }
800
+ $directory=Join-Path $BridgeDir ('sdk-reader-'+[guid]::NewGuid().ToString())
801
+ [void](New-Item -ItemType Directory -Path $directory -ErrorAction Stop)
802
+ Add-Type -Path (Join-Path $PSScriptRoot 'sdk-interop-importer.cs') -ErrorAction Stop
803
+ [void][MwSdkInteropImporter]::Generate($sdk,$directory)
804
+ if ((Get-MwSha256 $sdk) -ine $supported) { throw 'SDK changed during generation' }
805
+ $assembly=Join-Path $directory 'ADELib.dll'
806
+ $assemblyHash=Get-MwSha256 $assembly
807
+ [void][Reflection.Assembly]::LoadFrom($assembly)
808
+ Add-Type -Path (Join-Path $PSScriptRoot 'sdk-variable-reader.cs') -ReferencedAssemblies $assembly -ErrorAction Stop
809
+ if ((Get-MwSha256 $assembly) -ine $assemblyHash) { throw 'Generated assembly changed during loading' }
810
+ $script:SdkReaderFile=$sdk
811
+ $script:SdkReaderHash=$supported
812
+ $script:SdkReaderEnabled=$true
813
+ Log 'SDK variable reader enabled: verified installed SDK and locally generated interop'
814
+ } catch {
815
+ # Initialization reads SDK metadata only. No native declaration read has
816
+ # started, so the complete existing reader remains a safe fallback.
817
+ Log ('SDK variable reader initialization unavailable; using complete legacy reader: '+$_.Exception.Message)
818
+ }
819
+ }
820
+
821
+ function Get-VariableRows($vars) {
822
+ if ($script:SdkReaderEnabled) {
823
+ if ((Get-MwSha256 $script:SdkReaderFile) -ine $script:SdkReaderHash) { throw 'SDK changed after reader initialization; inspect before further native operations' }
824
+ # Never fall back or retry if a typed native read fails partway through.
825
+ return ,([MwSdkVariableReader]::Read($vars))
826
+ }
827
+ $sections=@{1='VAR';2='VAR_INPUT';3='VAR_OUTPUT';4='VAR_IN_OUT';5='VAR_EXTERNAL';6='VAR_GLOBAL'}
828
+ $rows=@()
829
+ $count=[int]$vars.Count
830
+ for($i=1;$i -le $count;$i++) {
831
+ $v=$vars.Item($i); $section=$sections[[int]$v.BlockType]
832
+ if(-not $section){throw 'Unsupported native declaration usage; no mutation allowed'}
833
+ $row=[ordered]@{name=[string]$v.Name;type=[string]$v.DataType;section=$section;group=[string]$v.Group.Name;
834
+ address=[string]$v.IecAddress;initial_value=[string]$v.InitialValue;description=[string]$v.Comment;
835
+ retain=[bool]$v.Retain;pdd=[bool]$v.PDD;opc=[bool]$v.OPC;disabled=[bool]$v.Disabled;not_on_plc=[bool]$v.NotOnPlc;redundant=[bool]$v.Redundant}
836
+ foreach($field in @('address','initial_value','description')){if($row[$field] -eq ''){$row[$field]=$null}}
837
+ $rows+=,$row
838
+ }
839
+ if([int]$vars.Count -ne $count){throw 'Native declaration count changed during inspection'}
840
+ return ,$rows
841
+ }
842
+ function Get-GroupMemberRows($vars) {
843
+ $groups=@();$collection=$vars.Groups;$count=[int]$collection.Count
844
+ for($i=1;$i -le $count;$i++){
845
+ $g=$collection.Item($i);$members=@();$variables=$g.Variables;$memberCount=[int]$variables.Count
846
+ for($j=1;$j -le $memberCount;$j++){$members+=,[string]$variables.Item($j).Name}
847
+ if([int]$variables.Count -ne $memberCount){throw 'Native group members changed during inspection'}
848
+ $groups+=,[ordered]@{name=[string]$g.Name;read_only=[bool]$g.ReadOnly;members=$members}
849
+ }
850
+ if([int]$collection.Count -ne $count){throw 'Native group count changed during inspection'}
851
+ return ,$groups
852
+ }
853
+ function Get-VariableGroupSnapshot($app,[string]$pou) {
854
+ $vars=Get-VariableSheet $app $pou;$structure=Get-NativeStructure $app
855
+ $membership=[ordered]@{globals=(Get-GroupMemberRows (Get-VariableSheet $app ''))}
856
+ $pous=$app.ActiveProject.Pous;$pouCount=[int]$pous.Count
857
+ if($pouCount -ne $structure.pous.Count){throw 'Native POU count changed during group inspection'}
858
+ for($i=1;$i -le $pouCount;$i++){
859
+ $p=$pous.Item($i);$name=[string]$p.Name
860
+ if($name -cne $structure.pous[$i-1].name){throw 'Native POU identity changed during group inspection'}
861
+ $membership['pou:'+$name]=Get-GroupMemberRows $p.Variables
862
+ }
863
+ $key=if($pou){'pou:'+$pou}else{'globals'}
864
+ $groups=@($membership[$key] | ForEach-Object {[ordered]@{name=$_.name;read_only=$_.read_only}})
865
+ $libraries=@();$collection=$app.ActiveProject.Libraries;$count=[int]$collection.Count
866
+ for($i=1;$i -le $count;$i++){$lib=$collection.Item($i);$libraries+=,[ordered]@{name=[string]$lib.Name;path=[string]$lib.FullName;logical_name=[string]$lib.LogicalName}}
867
+ if([int]$collection.Count -ne $count){throw 'Native library count changed during inspection'}
868
+ if(($structure | ConvertTo-Json -Depth 20 -Compress) -cne ((Get-NativeStructure $app) | ConvertTo-Json -Depth 20 -Compress)){throw 'Native structure changed during group inspection'}
869
+ return [ordered]@{variables=(Get-VariableRows $vars);groups=$groups;structure=$structure;libraries=$libraries;group_membership=$membership}
870
+ }
871
+ function Assert-VariableBaseline($expected,$actual) {
872
+ if($null -eq $expected -or $expected.Count -ne $actual.Count){throw 'REFUSED: native declaration baseline changed'}
873
+ foreach($row in $expected) {
874
+ $match=@($actual | Where-Object {$_.name -ieq $row.name})
875
+ if($match.Count -ne 1){throw 'REFUSED: native declaration baseline changed'}
876
+ foreach($field in @('name','type','section','group','address','initial_value','description','retain','pdd','opc','disabled','not_on_plc','redundant')) {
877
+ if($row.$field -cne $match[0].$field){throw "REFUSED: native baseline changed: $($row.name).$field"}
878
+ }
879
+ }
880
+ }
881
+ function Get-NativeStructure($app) {
882
+ $pous=@();$tasks=@();$types=@{7='PROGRAM';6='FUNCTION_BLOCK';24='FUNCTION'}
883
+ $collection=$app.ActiveProject.Pous
884
+ for($i=1;$i -le $collection.Count;$i++) {
885
+ $p=$collection.Item($i);$type=$types[[int]$p.PouType]
886
+ if(-not $type){throw 'Unsupported native POU type'}
887
+ $pous+=,[ordered]@{name=[string]$p.Name;type=$type;language=[int]$p.PouLanguage;
888
+ plc_type=[string]$p.PlcType;processor_type=[string]$p.ProcessorType;read_only=[bool]$p.ReadOnly}
889
+ }
890
+ $resource=$app.ActiveProject.GetObjectByLogicalName('Hardware/Configuration/Resource',10)
891
+ if(-not $resource){throw 'Exact resource not found'}
892
+ $collection=$resource.Tasks
893
+ for($i=1;$i -le $collection.Count;$i++) {
894
+ $t=$collection.Item($i);$instances=@()
895
+ for($j=1;$j -le $t.ProgramInstances.Count;$j++) {
896
+ $p=$t.ProgramInstances.Item($j)
897
+ $instances+=,[ordered]@{name=[string]$p.Name;type=[string]$p.Type}
898
+ }
899
+ $tasks+=,[ordered]@{name=[string]$t.Name;kind=[string]$t.Type;instances=$instances}
900
+ }
901
+ return [ordered]@{pous=$pous;tasks=$tasks}
902
+ }
903
+ function Normalize-MwPath([string]$path) {
904
+ if ([string]::IsNullOrWhiteSpace($path)) { return '' }
905
+ try { return [IO.Path]::GetFullPath($path).TrimEnd('\') } catch { return $path.Trim().TrimEnd('\') }
906
+ }
907
+
908
+ function Set-RequestScope($req) {
909
+ $verb = [string]$req.verb
910
+ # Scope is supplied for EACH request, never inherited from the last chat.
911
+ $script:WorkspaceRoot = $null
912
+ $script:StageRoot = ''
913
+ if ($verb -notin @('ping', 'stop')) {
914
+ if ([string]::IsNullOrWhiteSpace([string]$req.workspace) -or
915
+ -not [IO.Path]::IsPathRooted([string]$req.workspace)) {
916
+ throw 'REFUSED: the bridge request has no absolute session workspace.'
917
+ }
918
+ Assert-NoLinkedPath ([string]$req.workspace)
919
+ $script:WorkspaceRoot = Normalize-MwPath ([string]$req.workspace)
920
+ $script:StageRoot = Join-Path $script:WorkspaceRoot '.motionworks\stage'
921
+ Assert-NoLinkedPath $StageRoot
922
+ if (-not ((Normalize-MwPath ([string]$req.stage_root)).Equals((Normalize-MwPath $StageRoot), [StringComparison]::OrdinalIgnoreCase))) {
923
+ throw 'REFUSED: bridge stage does not match the request workspace.'
924
+ }
925
+ }
926
+ }
927
+
928
+ function Assert-NoLinkedPath([string]$path) {
929
+ $cursor = [IO.Path]::GetFullPath($path)
930
+ while ($cursor) {
931
+ # Check each ancestor afresh. FileSystem provider cmdlets are expensive
932
+ # across hundreds of backup members; the native attributes preserve the
933
+ # same reparse-point guard without caching paths across IDE operations.
934
+ $attributes = $null
935
+ try { $attributes = [IO.File]::GetAttributes($cursor) }
936
+ catch [IO.FileNotFoundException] { }
937
+ catch [IO.DirectoryNotFoundException] { }
938
+ if ($null -ne $attributes -and ($attributes -band [IO.FileAttributes]::ReparsePoint)) {
939
+ throw "REFUSED: linked path cannot be used by the IDE bridge: $cursor"
940
+ }
941
+ $parent = [IO.Path]::GetDirectoryName($cursor)
942
+ if ($parent -eq $cursor) { break }
943
+ $cursor = $parent
944
+ }
945
+ }
946
+
947
+ function Get-PouPackageState($app) {
948
+ $timing=[Diagnostics.Stopwatch]::StartNew()
949
+ Log 'package snapshot begin'
950
+ $project=$app.ActiveProject
951
+ $structure=Get-NativeStructure $app
952
+ Log ("package snapshot structure elapsed_ms={0}" -f $timing.ElapsedMilliseconds)
953
+ $pous=$project.Pous
954
+ $pouCount=[int]$pous.Count
955
+ if($pouCount -ne $structure.pous.Count){throw 'Native POU count changed during inspection'}
956
+ $resource=$project.GetObjectByLogicalName('Hardware/Configuration/Resource',10)
957
+ if(-not $resource){throw 'Exact resource not found'}
958
+ $sheets=[ordered]@{globals=$resource.Variables}
959
+ for($i=1;$i -le $pouCount;$i++){
960
+ $pou=$pous.Item($i);$name=[string]$pou.Name
961
+ if($name -cne $structure.pous[$i-1].name){throw 'Native POU identity changed during inspection'}
962
+ $sheets['pou:'+$name]=$pou.Variables
963
+ }
964
+ $declarations=[ordered]@{}
965
+ foreach($key in $sheets.Keys) {
966
+ $sheet=$sheets[$key];$groups=@();$collection=$sheet.Groups
967
+ $groupCount=[int]$collection.Count
968
+ Log ("package snapshot sheet={0} begin elapsed_ms={1}" -f $key,$timing.ElapsedMilliseconds)
969
+ for($i=1;$i -le $groupCount;$i++){$group=$collection.Item($i);$groups+=,[ordered]@{name=[string]$group.Name;read_only=[bool]$group.ReadOnly}}
970
+ if([int]$collection.Count -ne $groupCount){throw 'Native declaration groups changed during inspection'}
971
+ $declarations[$key]=[ordered]@{variables=(Get-VariableRows $sheet);groups=$groups}
972
+ Log ("package snapshot sheet={0} complete rows={1} elapsed_ms={2}" -f $key,$declarations[$key].variables.Count,$timing.ElapsedMilliseconds)
973
+ }
974
+ $libraries=@();$collection=$project.Libraries;$libraryCount=[int]$collection.Count
975
+ for($i=1;$i -le $libraryCount;$i++){$library=$collection.Item($i);$libraries+=,[ordered]@{name=[string]$library.Name;full_name=[string]$library.FullName;logical_name=[string]$library.LogicalName}}
976
+ if([int]$collection.Count -ne $libraryCount -or [int]$pous.Count -ne $pouCount){throw 'Native inventory changed during inspection'}
977
+ Log ("package snapshot complete elapsed_ms={0}" -f $timing.ElapsedMilliseconds)
978
+ return [ordered]@{structure=$structure;declarations=$declarations;libraries=$libraries}
979
+ }
980
+
981
+ function Get-PouPackageManifest([string]$directory) {
982
+ Assert-NoLinkedPath $directory
983
+ $files=@()
984
+ foreach($item in @(Get-ChildItem -LiteralPath $directory -Recurse -Force | Sort-Object FullName)) {
985
+ Assert-NoLinkedPath $item.FullName
986
+ if($item.PSIsContainer){continue}
987
+ $files+=,[ordered]@{path=$item.FullName.Substring($directory.TrimEnd('\').Length+1).Replace('\','/');bytes=[long]$item.Length;sha256=(Get-MwSha256 $item.FullName).ToLowerInvariant()}
988
+ }
989
+ return ,$files
990
+ }
991
+
992
+ function Test-InsideWorkspace([string]$path) {
993
+ if ([string]::IsNullOrWhiteSpace($path) -or -not $script:WorkspaceRoot) { return $false }
994
+ Assert-NoLinkedPath $path
995
+ $full = Normalize-MwPath $path
996
+ $root = Normalize-MwPath $script:WorkspaceRoot
997
+ return ($full.Equals($root, [StringComparison]::OrdinalIgnoreCase) -or
998
+ $full.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase))
999
+ }
1000
+
1001
+ function Test-InsideStage([string]$path) {
1002
+ if (-not (Test-InsideWorkspace $path)) { return $false }
1003
+ $full = Normalize-MwPath $path
1004
+ $root = Normalize-MwPath $StageRoot
1005
+ if (-not $full -or -not $root) { return $false }
1006
+ if ($full.Equals($root, [StringComparison]::OrdinalIgnoreCase) -or
1007
+ $full.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase)) { return $true }
1008
+ # Legacy callers use this guard for every authorized IDE project. A direct
1009
+ # workspace attachment is authorized only with its verified backup identity.
1010
+ return ($null -ne (Get-DirectIdentity $path))
1011
+ }
1012
+
1013
+ function Get-DirectIdentity([string]$path) {
1014
+ if (-not (Test-InsideWorkspace $path)) { return $null }
1015
+ $folder = Join-Path $script:WorkspaceRoot '.motionworks\attached\records'
1016
+ if (-not (Test-Path -LiteralPath $folder)) { return $null }
1017
+ foreach ($file in Get-ChildItem -LiteralPath $folder -Filter '*.json' -File) {
1018
+ Assert-NoLinkedPath $file.FullName
1019
+ try { $raw = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json }
1020
+ catch { continue }
1021
+ if ([string]$raw.mode -cne 'direct' -or -not (Test-SameProject ([string]$raw.mwt) $path)) { continue }
1022
+ $backupRoot = Normalize-MwPath (Join-Path $script:WorkspaceRoot '.motionworks\attached\backups')
1023
+ $backup = Normalize-MwPath ([string]$raw.backup)
1024
+ $manifestPath = Normalize-MwPath ([string]$raw.backup_manifest)
1025
+ if (-not (Test-InsideWorkspace $backup) -or -not (Test-InsideWorkspace $manifestPath)) { return $null }
1026
+ if (-not (Test-InsideWorkspace ([string]$raw.mwt)) -or
1027
+ -not (Test-InsideWorkspace ([string]$raw.directory)) -or
1028
+ -not ((Normalize-MwPath ([string]$raw.workspace)).Equals((Normalize-MwPath $script:WorkspaceRoot), [StringComparison]::OrdinalIgnoreCase)) -or
1029
+ -not (Test-SameProject ([string]$raw.directory) $path) -or
1030
+ -not (Test-Path -LiteralPath ([string]$raw.mwt)) -or
1031
+ -not (Test-Path -LiteralPath ([string]$raw.directory)) -or
1032
+ -not $backup.StartsWith($backupRoot + '\', [StringComparison]::OrdinalIgnoreCase) -or
1033
+ -not $manifestPath.StartsWith($backup + '\', [StringComparison]::OrdinalIgnoreCase) -or
1034
+ -not (Test-Path -LiteralPath ([string]$raw.backup_manifest))) { return $null }
1035
+ if ((Get-MwSha256 ([string]$raw.backup_manifest)) -ine [string]$raw.backup_manifest_sha256) { return $null }
1036
+ try {
1037
+ $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
1038
+ foreach ($item in @($manifest.files)) {
1039
+ $member = Normalize-MwPath (Join-Path $backup ([string]$item.path))
1040
+ if (-not $member.StartsWith($backup + '\', [StringComparison]::OrdinalIgnoreCase) -or
1041
+ -not (Test-InsideWorkspace $member) -or
1042
+ -not (Test-Path -LiteralPath $member)) { return $null }
1043
+ }
1044
+ } catch { return $null }
1045
+ return $raw
1046
+ }
1047
+ return $null
1048
+ }
1049
+
1050
+ function Test-SameProject([string]$active, [string]$requested) {
1051
+ $a = Normalize-MwPath $active
1052
+ $r = Normalize-MwPath $requested
1053
+ if (-not $a -or -not $r) { return $false }
1054
+ $bare = {
1055
+ param($p)
1056
+ if ($p.EndsWith('.mwt', [StringComparison]::OrdinalIgnoreCase)) { return $p.Substring(0, $p.Length - 4) }
1057
+ return $p
1058
+ }
1059
+ $aBare = & $bare $a
1060
+ $rBare = & $bare $r
1061
+ return $aBare.Equals($rBare, [StringComparison]::OrdinalIgnoreCase)
1062
+ }
1063
+
1064
+ function Get-MwSha256([string]$path) {
1065
+ $sha = [System.Security.Cryptography.SHA256]::Create()
1066
+ $stream = [System.IO.File]::OpenRead($path)
1067
+ try { return [System.BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-', '') }
1068
+ finally { $stream.Dispose(); $sha.Dispose() }
1069
+ }
1070
+
1071
+ function Get-OpenProjectPath($app) {
1072
+ try {
1073
+ if (-not $app.IsProjectOpen()) { return $null }
1074
+ $name = [string]$app.ActiveProject.FullName
1075
+ if ([string]::IsNullOrWhiteSpace($name)) { return $null }
1076
+ return $name
1077
+ } catch { return $null }
1078
+ }
1079
+
1080
+ # Every live IDE verb that compiles, saves, or reads the open project calls this.
1081
+ # A project outside stage/ is the wrong project — often one MotionWorks restored,
1082
+ # or one the .mwt wrapper still pointed at — and it is not edited from here.
1083
+ function Assert-ProvenCopy([string]$path) {
1084
+ if (Get-DirectIdentity $path) { return }
1085
+ # A directory under stage/ is not provenance. mw_ide_stage writes <name>.identity.json
1086
+ # with the workspace source. Compile and save refuse a copy that has none.
1087
+ $full = Normalize-MwPath $path
1088
+ if ($full.EndsWith('.mwt', [StringComparison]::OrdinalIgnoreCase)) {
1089
+ $full = $full.Substring(0, $full.Length - 4)
1090
+ }
1091
+ $name = [IO.Path]::GetFileName($full)
1092
+ $ident = Join-Path (Normalize-MwPath $StageRoot) ($name + '.identity.json')
1093
+ Assert-NoLinkedPath $ident
1094
+ if (-not (Test-Path -LiteralPath $ident)) {
1095
+ throw "REFUSED: '$path' has no identity file. Stage it from the workspace with mw_ide_stage. A project dropped into stage is not compiled or saved."
1096
+ }
1097
+ try { $raw = Get-Content -LiteralPath $ident -Raw -Encoding UTF8 | ConvertFrom-Json } catch {
1098
+ throw "REFUSED: could not read the identity for '$name'."
1099
+ }
1100
+ if ([string]::IsNullOrWhiteSpace([string]$raw.source)) {
1101
+ throw "REFUSED: staged project '$name' has no recorded source. Stage it again from the workspace project before compiling or saving it."
1102
+ }
1103
+ if (-not (Test-InsideStage $path) -or
1104
+ -not (Test-InsideWorkspace ([string]$raw.source)) -or
1105
+ -not (Test-InsideWorkspace ([string]$raw.source_directory)) -or
1106
+ -not ((Normalize-MwPath ([string]$raw.workspace)).Equals((Normalize-MwPath $script:WorkspaceRoot), [StringComparison]::OrdinalIgnoreCase)) -or
1107
+ -not (Test-SameProject ([string]$raw.staged_mwt) $path) -or
1108
+ -not (Test-SameProject ([string]$raw.staged_directory) $path) -or
1109
+ -not (Test-SameProject ([string]$raw.bound_to) $path)) {
1110
+ throw "REFUSED: project identity does not belong to this request's workspace."
1111
+ }
1112
+
1113
+ }
1114
+
1115
+ function Assert-StagedOpen($app, [string]$what) {
1116
+ $path = Get-OpenProjectPath $app
1117
+ if (-not $path) { throw 'no project is open in the IDE' }
1118
+ # A direct identity already proves workspace containment and the backup.
1119
+ # Avoid traversing that same backup again through both legacy stage guards.
1120
+ # This is local to this invocation: later verbs validate a fresh identity.
1121
+ if (Get-DirectIdentity $path) { return $path }
1122
+ if (-not (Test-InsideStage $path)) {
1123
+ throw "REFUSED: $what would act on unverified project '$path' in workspace '$script:WorkspaceRoot'. Attach the exact workspace project with mw_ide_attach and a verified backup before editing."
1124
+ }
1125
+ Assert-ProvenCopy $path
1126
+ return $path
1127
+ }
1128
+
1129
+ function Assert-CloseConsent($request, [string]$current, [string]$what) {
1130
+ if (-not [bool]$request.user_approved) {
1131
+ throw "REFUSED: $what requires the user's approval to close MotionWorks. Ask first, then pass user_approved=true."
1132
+ }
1133
+ $expected = [string]$request.expected_project
1134
+ if (-not $current -and $expected) {
1135
+ throw "REFUSED: MotionWorks no longer has the approved project '$expected' open. Check IDE status and ask again before closing it."
1136
+ }
1137
+ if ($current) {
1138
+ if (-not $expected -or -not (Test-SameProject $expected $current)) {
1139
+ throw "REFUSED: MotionWorks now has '$current' open, which does not match the approved project '$expected'. Ask the user again before closing it."
1140
+ }
1141
+ }
1142
+ }
1143
+
1144
+ Log "bridge started pid=$PID bits=$([IntPtr]::Size * 8) stage=$StageRoot"
1145
+
1146
+ while ($true) {
1147
+ if (-not (Test-Path $reqPath)) { Start-Sleep -Milliseconds $PollMs; continue }
1148
+
1149
+ $raw = $null; $req = $null
1150
+ try { $raw = Get-Content -Path $reqPath -Raw -Encoding UTF8; $req = $raw | ConvertFrom-Json }
1151
+ catch { Start-Sleep -Milliseconds $PollMs; continue } # half-written; retry
1152
+ if ($null -eq $req -or $null -eq $req.verb) { Start-Sleep -Milliseconds $PollMs; continue }
1153
+
1154
+ $id = $req.id
1155
+ $verb = [string]$req.verb
1156
+ Log "req id=$id verb=$verb"
1157
+
1158
+ $data = $null; $err = $null; $ok = $false
1159
+ try {
1160
+ Set-RequestScope $req
1161
+ if ($verb -in @('variable_snapshot','variable_mutate','variable_group_snapshot','variable_group_mutate','pou_package_snapshot','pou_package_mutate','pou_convert','code_mutate')) {
1162
+ Initialize-SdkVariableReader
1163
+ }
1164
+ switch ($verb) {
1165
+
1166
+ 'ping' {
1167
+ $ok = $true
1168
+ $data = [ordered]@{ alive = $true; workspace_protocol = 6; ide_window = (Get-IdeWindow -ne $null) }
1169
+ }
1170
+
1171
+ 'stop' {
1172
+ # Consume the request BEFORE exiting. Leaving req.json behind means
1173
+ # the next bridge to start immediately reads this stale stop and
1174
+ # dies on launch.
1175
+ Remove-Item -Force $reqPath -ErrorAction SilentlyContinue
1176
+ Write-Result $id $verb $true ([ordered]@{ stopping = $true }) $null
1177
+ Log "stop requested; exiting"
1178
+ Remove-BridgeLock
1179
+ if ($script:App -ne $null) { try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($script:App) } catch { } }
1180
+ exit 0
1181
+ }
1182
+
1183
+ 'status' {
1184
+ $app = Connect-App
1185
+ $ideW = Get-IdeWindow
1186
+ $isOpen = $null; $activeName = $null
1187
+ try { $isOpen = $app.IsProjectOpen() } catch { $isOpen = $null }
1188
+ if ($isOpen) { try { $activeName = $app.ActiveProject.FullName } catch { } }
1189
+ $directIdentity = if ($activeName) { Get-DirectIdentity $activeName } else { $null }
1190
+ $inStage = $false
1191
+ if ($directIdentity) { $inStage = $true }
1192
+ elseif ($activeName) { $inStage = Test-InsideStage $activeName }
1193
+ $ok = $true
1194
+ $data = [ordered]@{
1195
+ version = [string]$app.Version
1196
+ ide_window = ("0x{0:X}" -f ([int64]$ideW))
1197
+ is_project_open = $isOpen
1198
+ active_project = $activeName
1199
+ in_stage = [bool]$inStage
1200
+ project_mode = $(if ($directIdentity) { 'direct' } elseif ($inStage) { 'stage' } else { 'unverified' })
1201
+ }
1202
+ }
1203
+
1204
+ 'active_view' {
1205
+ $app = Connect-App
1206
+ [void](Assert-StagedOpen $app $verb)
1207
+ $view = $null; $viewError = $null
1208
+ try { $view = [string]$app.ActiveProject.GetLogicalNameOfActiveView() }
1209
+ catch { $viewError = $_.Exception.Message }
1210
+ if (-not $view -and -not $viewError) { $viewError = 'No active worksheet reported by the IDE' }
1211
+ $ok = $true
1212
+ $ui=Get-IdeUiStatus
1213
+ $data = [ordered]@{ active_project = [string]$app.ActiveProject.FullName; logical_name = $view; error = $viewError;
1214
+ ui_responsive=$ui.responding; editor_caption=$ui.caption; keyboard_focus_verified=$false }
1215
+ }
1216
+
1217
+ 'open_worksheet' {
1218
+ if($null -ne $req.inspect_only -and $req.inspect_only -isnot [bool]){throw 'REFUSED: inspect_only must be a boolean'}
1219
+ $app=Connect-App
1220
+ [void](Assert-StagedOpen $app $verb)
1221
+ if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine [IO.Path]::GetFullPath([string]$req.project+'.mwt')) {throw 'REFUSED: wrong native project'}
1222
+ $view=[string]$req.logical_name
1223
+ $urn=[string]$req.urn
1224
+ $documentView=[string]$req.document_logical_name
1225
+ $pouName=$null;$sheetName=$null
1226
+ if($documentView -cmatch '^/Pous/([^/.\\]+)/([^/.\\]+)$') {
1227
+ $pouName=$Matches[1];$sheetName=$Matches[2]
1228
+ $expectedCaption='{0}:{1}' -f $sheetName,$pouName
1229
+ [void](Get-VariableSheet $app $pouName)
1230
+ if($urn -cne ('@POUS.'+$pouName+'.'+$sheetName)){throw 'REFUSED: inconsistent worksheet identity'}
1231
+ $expectedView=if($req.kind -eq 'code'){'/Pous/'+$pouName}else{$documentView}
1232
+ if($view -cne $expectedView){throw 'REFUSED: inconsistent active-view identity'}
1233
+ } elseif($documentView -cmatch '^/Hardware/([^/.\\]+)/([^/.\\]+)/([^/.\\]+)$') {
1234
+ $config=$Matches[1];$resource=$Matches[2];$sheet=$Matches[3]
1235
+ $expectedCaption='{0}:{1}.{2}' -f $sheet,$config,$resource
1236
+ if($req.kind -ne 'variables' -or $urn -cne ('@HW.'+$config+'.'+$resource+'.'+$sheet)){throw 'REFUSED: inconsistent globals identity'}
1237
+ if($view -cne $documentView){throw 'REFUSED: inconsistent active-view identity'}
1238
+ [void]$app.ActiveProject.GetObjectByLogicalName(('Hardware/'+$config+'/'+$resource),10)
1239
+ } else {throw 'REFUSED: unsupported worksheet identity'}
1240
+ $uiBefore=Get-IdeUiStatus
1241
+ if($uiBefore.responding -ne $true){throw 'REFUSED: IDE frame responsiveness is false or unknown; inspect the live window before navigation'}
1242
+ $beforeModified=$app.ActiveProject.IsModified
1243
+ $inspectOnly=($req.inspect_only -eq $true)
1244
+ if(-not $inspectOnly){
1245
+ $userData=[object[]]@()
1246
+ [void]$app.OpenDocument($urn,$true,[ref]$userData)
1247
+ }
1248
+ # COM can report the new view before its visible editor settles.
1249
+ # Observe two responsive, matching frames without repeating OpenDocument.
1250
+ $deadline=(Get-Date).AddSeconds(3);$readySamples=0;$instanceView=$null
1251
+ do {
1252
+ $actual=[string]$app.ActiveProject.GetLogicalNameOfActiveView()
1253
+ $afterModified=$app.ActiveProject.IsModified
1254
+ $acceptedView=$view;$acceptedCaption=$expectedCaption
1255
+ if($pouName -and $actual -cne $view){
1256
+ if(-not $instanceView -or $instanceView.logical_name -cne $actual){
1257
+ $instanceView=Get-WorksheetInstanceView $app $actual $pouName $sheetName
1258
+ }
1259
+ if($instanceView){$acceptedView=$instanceView.logical_name;$acceptedCaption=$instanceView.expected_caption}
1260
+ }
1261
+ $editorState=Get-WorksheetEditorState $acceptedView $actual $beforeModified $afterModified (Get-IdeUiStatus) $acceptedCaption
1262
+ if($editorState.editor_window_ready){$readySamples++}else{$readySamples=0}
1263
+ if($readySamples -ge 2){break}
1264
+ Start-Sleep -Milliseconds 250
1265
+ }while((Get-Date) -lt $deadline)
1266
+ $ok=$true
1267
+ $data=[ordered]@{accepted=($readySamples -ge 2);
1268
+ action_performed=(-not $inspectOnly);method=$(if($inspectOnly){'native_com_inspection'}else{'native_com_urn'});active_project=[string]$app.ActiveProject.FullName;
1269
+ requested_logical_name=$view;logical_name=$actual;urn=$urn;is_modified=$afterModified;
1270
+ modified_state_unchanged=($beforeModified -is [bool] -and $afterModified -is [bool] -and $beforeModified -eq $afterModified);
1271
+ editor_settled=($readySamples -ge 2);ready_observations=$readySamples;
1272
+ instance_view_verified=([bool]$instanceView -and $instanceView.logical_name -ceq $actual);
1273
+ next_step=$(if($readySamples -ge 2){'Observe current editor focus through the connected computer tool before keyboard input. Native declaration/code APIs do not need editor focus.'}else{'STOP: the current visible editor does not match or has not settled. Use inspect_only:true to check readiness without repeating navigation; do not send keys automatically.'})}
1274
+ foreach($key in $editorState.Keys){$data[$key]=$editorState[$key]}
1275
+ }
1276
+
1277
+ 'open' {
1278
+ # The cached Application can describe a project that no longer
1279
+ # exists once this has run; drop it so the next verb reconnects.
1280
+ $script:App = $null
1281
+ $path = [string]$req.path
1282
+ if ([string]::IsNullOrWhiteSpace($path)) { throw 'open requires "path"' }
1283
+ # Fresh dialog budget for this call, so a long session of opens cannot
1284
+ # inherit a spent budget and silently stop answering prompts.
1285
+ $script:dialogSpent = 0.0
1286
+ $swOpen = [Diagnostics.Stopwatch]::StartNew()
1287
+ $full = [IO.Path]::GetFullPath($path)
1288
+ $stageFull = [IO.Path]::GetFullPath($StageRoot)
1289
+ if (-not (Test-InsideStage $full)) {
1290
+ throw "REFUSED: '$full' is outside the staging root '$stageFull'. Only staged copies may be opened."
1291
+ }
1292
+ if (-not (Test-Path $full)) { throw "project not found: $full" }
1293
+ Assert-ProvenCopy $full
1294
+ $digest = Get-MwSha256 $full
1295
+ if (-not $req.wrapper_sha256 -or $digest -ne [string]$req.wrapper_sha256) {
1296
+ throw 'REFUSED: wrapper binding must be verified by mw_ide_open before opening.'
1297
+ }
1298
+ $app = Connect-App
1299
+ # A project already in the window may belong to the user. Never replace it
1300
+ # without approval tied to its exact path, even when it is unmodified.
1301
+ $dismissed = $null
1302
+ $prior = Get-OpenProjectPath $app
1303
+ if ($prior -and -not (Test-SameProject $prior $full)) {
1304
+ Assert-CloseConsent $req $prior 'opening another project'
1305
+ try {
1306
+ # Consent covers saving and closing the named current project.
1307
+ $app.ActiveProject.Save()
1308
+ $app.ActiveProject.Close($false)
1309
+ $dismissed = $prior
1310
+ Log "closed other project before open: $prior"
1311
+ } catch {
1312
+ throw "REFUSED: the IDE has '$prior' open and it could not be closed ($($_.Exception.Message)). The staged project '$full' was not opened."
1313
+ }
1314
+ }
1315
+ # From Ade.tlb: OpenProject(Name, ConfirmConvert). ConfirmConvert=$false
1316
+ # suppresses the conversion prompt, which would otherwise block an
1317
+ # unattended flow on a modal dialog nobody is there to answer.
1318
+ #
1319
+ # RETRY on "Internal error": a freshly launched IDE has not finished
1320
+ # initialising its project services, and OpenProject fails with
1321
+ # "Internal error in 'OpenProject'" until it has. Reporting that as a
1322
+ # failure made start->open look broken when it only needed time.
1323
+ $opened = $false; $lastErr = $null
1324
+ # A PARALLEL WATCHER, because this thread is about to be blocked.
1325
+ #
1326
+ # OpenProject is a synchronous out-of-process COM call. When
1327
+ # MotionWorks raises a prompt DURING the load ("this project was not
1328
+ # closed cleanly, load anyway?"), the server enters a modal loop and
1329
+ # the call does not return until that prompt is answered. So the retry
1330
+ # loop below cannot help - no catch ever fires, no timeout applies.
1331
+ # Measured: the request was logged and then sat for minutes unanswered.
1332
+ # Only another process can press that button, so start one.
1333
+ $watcher = $null
1334
+ try {
1335
+ # Never let watchers accumulate. A watcher is started per open and
1336
+ # killed when the load finishes, but any path that throws first
1337
+ # leaves one behind - and FOUR were observed running at once after
1338
+ # a run of failed opens. Overlapping watchers all click whatever
1339
+ # dialog they find, which is exactly the kind of interference that
1340
+ # makes an open fail for no visible reason. Clear them first.
1341
+ try {
1342
+ Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue |
1343
+ Where-Object { $_.CommandLine -and $_.CommandLine -match 'watch_dialogs' } |
1344
+ ForEach-Object {
1345
+ Log "clearing stale dialog watcher pid=$($_.ProcessId)"
1346
+ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
1347
+ }
1348
+ } catch { }
1349
+
1350
+ $watchCmd = Join-Path $BridgeDir 'watch_dialogs.cmd'
1351
+ if (Test-Path $watchCmd) {
1352
+ $watcher = Start-Process -FilePath 'cmd.exe' `
1353
+ -ArgumentList '/c', "`"$watchCmd`" 200" `
1354
+ -WindowStyle Hidden -PassThru
1355
+ Log "dialog watcher started pid=$($watcher.Id)"
1356
+ }
1357
+ } catch { Log "dialog watcher failed to start: $($_.Exception.Message)" }
1358
+
1359
+ # 60s, not 90s: the retry window, the post-load dialog answers and the
1360
+ # verification loop all share the caller's timeout, and the client
1361
+ # gives up at 150s. Sizing each part as if it had the budget alone is
1362
+ # what produced "timed out waiting for the bridge".
1363
+ #
1364
+ # The attempt cap matters as much as the deadline. A blocked
1365
+ # OpenProject takes ~30s to fail, and the deadline is only consulted
1366
+ # BETWEEN attempts, so an unbounded loop overshoots wildly: measured
1367
+ # a "90s" loop running 443 seconds and a "60s" loop running 233.
1368
+ # Three attempts is enough to cover IDE warm-up and still return
1369
+ # inside the caller's budget.
1370
+ $deadline = (Get-Date).AddSeconds(60)
1371
+ $pass = 0
1372
+ while ((Get-Date) -lt $deadline -and $pass -lt 3) {
1373
+ $pass++
1374
+ # Answer any prompt the IDE is waiting on BEFORE calling again.
1375
+ # While a modal dialog is up the frame is disabled and
1376
+ # OpenProject cannot succeed, so retrying blindly just burns
1377
+ # the deadline - which is what made start->open look broken.
1378
+ # Checked on a stride, because inspection is not free while the
1379
+ # IDE's UI thread is busy.
1380
+ if ($pass % 3 -eq 1) { [void](Resolve-KnownDialogs) }
1381
+ try { $app.OpenProject($full, $false); $opened = $true; break }
1382
+ catch {
1383
+ $lastErr = $_.Exception.Message
1384
+ if ($lastErr -notmatch 'Internal error') { throw }
1385
+ Start-Sleep -Seconds 2
1386
+ }
1387
+ }
1388
+ Log "open attempt(s)=$pass opened=$opened elapsed=$([Math]::Round($swOpen.Elapsed.TotalSeconds,1))s requested=$full"
1389
+ if (-not $opened) {
1390
+ if ($watcher) { try { Stop-Process -Id $watcher.Id -Force -ErrorAction SilentlyContinue } catch { } }
1391
+ throw "OpenProject never succeeded within 60s; last error: $lastErr"
1392
+ }
1393
+ # Verify what actually happened rather than trusting the call.
1394
+ # MotionWorks asks "defragment?" / "load anyway?" DURING and AFTER
1395
+ # the load, so keep answering while we wait for services to appear.
1396
+ #
1397
+ # Dialogs are inspected every 4th pass, not every pass: while the IDE
1398
+ # is loading, its UI thread is busy and each cross-process text read
1399
+ # waits out its timeout, so inspecting on all 40 passes cost more than
1400
+ # the whole budget and the open call never returned.
1401
+ $verified = $null; $activeName = $null
1402
+ for ($i = 0; $i -lt 40; $i++) {
1403
+ Start-Sleep -Milliseconds 500
1404
+ if ($i % 4 -eq 0) { [void](Resolve-KnownDialogs) }
1405
+ try { $verified = $app.IsProjectOpen(); if ($verified) { $activeName = $app.ActiveProject.FullName; break } } catch { }
1406
+ }
1407
+ # The load is over - either way the watcher has nothing left to do, and
1408
+ # leaving it running would let it answer a prompt raised by some later,
1409
+ # unrelated step.
1410
+ if ($watcher) {
1411
+ try { Stop-Process -Id $watcher.Id -Force -ErrorAction SilentlyContinue; Log "dialog watcher stopped" } catch { }
1412
+ }
1413
+ $matches = Test-SameProject $activeName $full
1414
+ $inStage = Test-InsideStage $activeName
1415
+ Log "open loaded active=$activeName matches=$matches inStage=$inStage"
1416
+ if ($verified -and (-not $matches -or -not $inStage)) {
1417
+ throw "REFUSED: asked to open '$full' but the IDE loaded '$activeName'. It was left open; ask the user before saving or closing it."
1418
+ }
1419
+ $ok = [bool]$verified -and $matches -and $inStage
1420
+ $data = [ordered]@{
1421
+ requested = $full
1422
+ is_project_open = [bool]$verified
1423
+ active_project = $activeName
1424
+ matches_request = [bool]$matches
1425
+ in_stage = [bool]$inStage
1426
+ dismissed_project = $dismissed
1427
+ }
1428
+ if (-not $verified) {
1429
+ $blocking = @(Get-IdeDialogs)
1430
+ if ($blocking.Count -gt 0) {
1431
+ throw "OpenProject did not complete: the IDE is blocked by an unanswered dialog - '$($blocking[0].message)' (buttons: $(($blocking[0].buttons | ForEach-Object { $_.label }) -join ', ')). The IDE is NOT closed. Answer it, then re-check."
1432
+ }
1433
+ $err = 'OpenProject was called but IsProjectOpen never became true'
1434
+ }
1435
+ }
1436
+
1437
+ 'pous' {
1438
+ $app = Connect-App
1439
+ [void](Assert-StagedOpen $app $verb)
1440
+ $col = $app.ActiveProject.Pous
1441
+ $items = @()
1442
+ for ($i = 1; $i -le $col.Count; $i++) {
1443
+ $p = $col.Item($i)
1444
+ $items += [ordered]@{ index = $i; name = [string]$p.Name; language = [string]$p.PouLanguage }
1445
+ }
1446
+ $ok = $true
1447
+ $data = [ordered]@{ count = $col.Count; pous = $items }
1448
+ }
1449
+
1450
+ # Save the open project through the IDE.
1451
+ #
1452
+ # Worth having for its own sake, but added to test a specific suspicion: the
1453
+ # plugin edits the project ON DISK while the IDE is closed, so when the IDE
1454
+ # reopens it may hold a model that disagrees with the files. A compile then
1455
+ # runs against an inconsistent view and can report errors that are not in the
1456
+ # project - which would explain why adding a program to a task turns a clean
1457
+ # build into 125 unresolved-external errors while the files are provably fine.
1458
+ 'task_model' {
1459
+ $app = Connect-App
1460
+ [void](Assert-StagedOpen $app $verb)
1461
+ $tasks = [ordered]@{}
1462
+ $taskNames = @()
1463
+ # The resource lookup is the one call here that can fail outright, and it used to
1464
+ # take the whole verb down with it: an object model that does not answer for this
1465
+ # path returned a response the caller could not read, so `mw_code_task_model`
1466
+ # failed with a type error rather than reporting anything about the tasks. The
1467
+ # Fail explicitly instead of inventing a task inventory.
1468
+ try {
1469
+ $resource = $app.ActiveProject.GetObjectByLogicalName(
1470
+ 'Hardware/Configuration/Resource', 10)
1471
+ } catch {
1472
+ throw "task_model: resource lookup failed ($($_.Exception.Message))"
1473
+ }
1474
+ if ($resource -ne $null) {
1475
+ try {
1476
+ $coll = $resource.Tasks
1477
+ for ($i = 1; $i -le $coll.Count; $i++) { $taskNames += $coll.Item($i).Name }
1478
+ } catch {
1479
+ throw "task_model: reading the task collection failed ($($_.Exception.Message))"
1480
+ }
1481
+ }
1482
+ foreach ($name in $taskNames) {
1483
+ $path = "Hardware/Configuration/Resource/Tasks/$name"
1484
+ try {
1485
+ $task = $app.ActiveProject.GetObjectByLogicalName($path, 11)
1486
+ if ($task -eq $null) { continue }
1487
+ $pi = $task.ProgramInstances
1488
+ $instances = @()
1489
+ for ($j = 1; $j -le $pi.Count; $j++) {
1490
+ $inst = $pi.Item($j)
1491
+ $instances += [ordered]@{
1492
+ name = [string]$inst.Name
1493
+ type = [string]$inst.Type
1494
+ logical_name = [string]$inst.LogicalName
1495
+ }
1496
+ }
1497
+ $tasks[$name] = [ordered]@{
1498
+ name = [string]$task.Name
1499
+ cycle = [string]$task.Type
1500
+ logical_name = [string]$task.LogicalName
1501
+ instances = $instances
1502
+ }
1503
+ } catch {
1504
+ $tasks[$name] = [ordered]@{ error = $_.Exception.Message }
1505
+ }
1506
+ }
1507
+ $ok = $true
1508
+ $data = [ordered]@{ tasks = $tasks; source = 'com' }
1509
+ }
1510
+
1511
+ 'create_task' {
1512
+ $app = Connect-App
1513
+ [void](Assert-StagedOpen $app $verb)
1514
+ $name = [string]$req.name
1515
+ $kind = [string]$req.kind
1516
+ if ([string]::IsNullOrWhiteSpace($name)) { throw 'create_task requires "name"' }
1517
+ if ($name.Length -gt 7) { throw "MotionWorks IEC task names are limited to 7 characters; '$name' has $($name.Length)." }
1518
+ if ([string]::IsNullOrWhiteSpace($kind)) { $kind = 'CYCLIC' }
1519
+
1520
+ $resource = $app.ActiveProject.GetObjectByLogicalName(
1521
+ 'Hardware/Configuration/Resource', 10)
1522
+ if ($resource -eq $null) { throw 'no resource found in the active project' }
1523
+ $tasks = $resource.Tasks
1524
+ $before = $tasks.Count
1525
+
1526
+ foreach ($i in 1..$before) {
1527
+ if ([string]$tasks.Item($i).Name -eq $name) {
1528
+ throw "a task named '$name' already exists"
1529
+ }
1530
+ }
1531
+
1532
+ $tasks.GetType().InvokeMember('Create', 'InvokeMethod', $null, $tasks,
1533
+ @([string]$name, [string]$kind)) | Out-Null
1534
+ $after = $tasks.Count
1535
+
1536
+ $app.ActiveProject.Save()
1537
+ Start-Sleep -Milliseconds 400
1538
+
1539
+ $names = @()
1540
+ for ($i = 1; $i -le $tasks.Count; $i++) { $names += [string]$tasks.Item($i).Name }
1541
+ $made = $null
1542
+ for ($i = 1; $i -le $tasks.Count; $i++) {
1543
+ if ([string]$tasks.Item($i).Name -eq $name) { $made = $tasks.Item($i) }
1544
+ }
1545
+ $ok = $true
1546
+ $data = [ordered]@{
1547
+ created = $true
1548
+ name = $name
1549
+ cycle = $kind
1550
+ before = $before
1551
+ after = $after
1552
+ tasks = $names
1553
+ logical_name = if ($made) { [string]$made.LogicalName } else { $null }
1554
+ }
1555
+ }
1556
+
1557
+ 'delete_task' {
1558
+ $app = Connect-App
1559
+ [void](Assert-StagedOpen $app $verb)
1560
+ $name = [string]$req.name
1561
+ if ([string]::IsNullOrWhiteSpace($name)) { throw 'delete_task requires "name"' }
1562
+
1563
+ $resource = $app.ActiveProject.GetObjectByLogicalName(
1564
+ 'Hardware/Configuration/Resource', 10)
1565
+ if ($resource -eq $null) { throw 'no resource found in the active project' }
1566
+ $tasks = $resource.Tasks
1567
+ $before = $tasks.Count
1568
+
1569
+ $hit = -1
1570
+ for ($i = 1; $i -le $tasks.Count; $i++) {
1571
+ if ([string]$tasks.Item($i).Name -eq $name) { $hit = $i; break }
1572
+ }
1573
+ if ($hit -lt 0) {
1574
+ $names = @()
1575
+ for ($i = 1; $i -le $tasks.Count; $i++) { $names += [string]$tasks.Item($i).Name }
1576
+ throw "no task named '$name'. Tasks: $($names -join ', ')"
1577
+ }
1578
+ $task = $tasks.Item($hit)
1579
+ $kids = $task.ProgramInstances.Count
1580
+ if ($kids -gt 0) {
1581
+ throw "task '$name' still has $kids program instance(s) assigned; unassign them first"
1582
+ }
1583
+ $task.Delete()
1584
+ $after = $tasks.Count
1585
+
1586
+ $app.ActiveProject.Save()
1587
+ Start-Sleep -Milliseconds 400
1588
+
1589
+ $names = @()
1590
+ for ($i = 1; $i -le $tasks.Count; $i++) { $names += [string]$tasks.Item($i).Name }
1591
+ $ok = $true
1592
+ $data = [ordered]@{
1593
+ deleted = $true
1594
+ name = $name
1595
+ before = $before
1596
+ after = $after
1597
+ tasks = $names
1598
+ }
1599
+ }
1600
+
1601
+ 'assign_pou' {
1602
+ $app = Connect-App
1603
+ [void](Assert-StagedOpen $app $verb)
1604
+ $taskName = [string]$req.task
1605
+ $pouName = [string]$req.pou
1606
+ $instName = $req.instance
1607
+ if ([string]::IsNullOrWhiteSpace($instName)) { $instName = $pouName }
1608
+ $progType = $req.type
1609
+ if ([string]::IsNullOrWhiteSpace($progType)) { $progType = $pouName }
1610
+ if ([string]::IsNullOrWhiteSpace($taskName)) { throw 'assign requires "task"' }
1611
+ if ([string]::IsNullOrWhiteSpace($pouName)) { throw 'assign requires "pou"' }
1612
+
1613
+ $path = "Hardware/Configuration/Resource/Tasks/$taskName"
1614
+ $task = $app.ActiveProject.GetObjectByLogicalName($path, 11)
1615
+ if ($task -eq $null) { throw "no task named '$taskName' (looked for $path)" }
1616
+
1617
+ $pi = $task.ProgramInstances
1618
+ $before = $pi.Count
1619
+ $pi.GetType().InvokeMember('Create', 'InvokeMethod', $null, $pi,
1620
+ @([string]$instName, [string]$progType)) | Out-Null
1621
+ $after = $pi.Count
1622
+
1623
+ $app.ActiveProject.Save()
1624
+ Start-Sleep -Milliseconds 400
1625
+
1626
+ $instances = @()
1627
+ for ($j = 1; $j -le $pi.Count; $j++) { $instances += [string]$pi.Item($j).Name }
1628
+ $ok = $true
1629
+ $data = [ordered]@{
1630
+ assigned = $true
1631
+ task = $taskName
1632
+ pou = $pouName
1633
+ instance = [string]$instName
1634
+ type = [string]$progType
1635
+ before = $before
1636
+ after = $after
1637
+ instances = $instances
1638
+ logical_name = [string]$pi.Item($after).LogicalName
1639
+ }
1640
+ }
1641
+
1642
+ 'unassign_pou' {
1643
+ $app = Connect-App
1644
+ [void](Assert-StagedOpen $app $verb)
1645
+ $taskName = [string]$req.task
1646
+ $pouName = [string]$req.pou
1647
+ if ([string]::IsNullOrWhiteSpace($taskName)) { throw 'unassign requires "task"' }
1648
+ if ([string]::IsNullOrWhiteSpace($pouName)) { throw 'unassign requires "pou"' }
1649
+
1650
+ $path = "Hardware/Configuration/Resource/Tasks/$taskName"
1651
+ $task = $app.ActiveProject.GetObjectByLogicalName($path, 11)
1652
+ if ($task -eq $null) { throw "no task named '$taskName' (looked for $path)" }
1653
+
1654
+ $pi = $task.ProgramInstances
1655
+ $before = $pi.Count
1656
+ $hit = -1
1657
+ for ($j = 1; $j -le $pi.Count; $j++) {
1658
+ if ([string]$pi.Item($j).Name -eq $pouName) { $hit = $j; break }
1659
+ }
1660
+ if ($hit -lt 0) {
1661
+ $names = @()
1662
+ for ($j = 1; $j -le $pi.Count; $j++) { $names += [string]$pi.Item($j).Name }
1663
+ throw "'$pouName' is not assigned to task '$taskName'. Assigned: $($names -join ', ')"
1664
+ }
1665
+ $pi.Item($hit).Delete()
1666
+ $after = $pi.Count
1667
+
1668
+ $app.ActiveProject.Save()
1669
+ Start-Sleep -Milliseconds 400
1670
+
1671
+ $instances = @()
1672
+ for ($j = 1; $j -le $pi.Count; $j++) { $instances += [string]$pi.Item($j).Name }
1673
+ $ok = $true
1674
+ $data = [ordered]@{
1675
+ unassigned = $true
1676
+ task = $taskName
1677
+ pou = $pouName
1678
+ before = $before
1679
+ after = $after
1680
+ instances = $instances
1681
+ }
1682
+ }
1683
+
1684
+ 'save' {
1685
+ $app = Connect-App
1686
+ [void](Assert-StagedOpen $app $verb)
1687
+ $swSave = [Diagnostics.Stopwatch]::StartNew()
1688
+ $app.ActiveProject.Save()
1689
+ $swSave.Stop()
1690
+ Start-Sleep -Milliseconds 500
1691
+ $ok = $true
1692
+ $data = [ordered]@{
1693
+ saved = $true
1694
+ elapsed_s = [math]::Round($swSave.Elapsed.TotalSeconds, 2)
1695
+ is_modified = [bool]$app.ActiveProject.IsModified
1696
+ is_compiled = [bool]$app.ActiveProject.IsCompiled
1697
+ }
1698
+ }
1699
+
1700
+ 'compile_state' {
1701
+ $app = Connect-App
1702
+ [void](Assert-StagedOpen $app $verb)
1703
+ $ok = $true
1704
+ $data = [ordered]@{
1705
+ is_compiled = [bool]$app.ActiveProject.IsCompiled
1706
+ is_modified = [bool]$app.ActiveProject.IsModified
1707
+ }
1708
+ }
1709
+
1710
+ # Compile types, read from Ade.tlb. The earlier 1=Build / 2=Rebuild guess
1711
+ # was WRONG:
1712
+ # 1 adeCtMake 2 adeCtBuild 3 adeCtPatch 4 adeCtWorksheet 5 adeCtDataTypes
1713
+ # There is no Rebuild compile type: Rebuild is a COMMAND
1714
+ # (adeCmdBuildRebuildProject = 36570), and ExecuteCommand is a stub in
1715
+ # this build ("The method or operation is not implemented"), so Rebuild
1716
+ # is not reachable programmatically at all.
1717
+ 'make' { $mode = 1 }
1718
+ 'build' { $mode = 2 }
1719
+ 'rebuild' { $mode = $null }
1720
+ 'patch' { $mode = 3 }
1721
+ 'worksheet' { $mode = 4 }
1722
+ 'datatypes' { $mode = 5 }
1723
+
1724
+ # Resolve a menu command NAME to its numeric id without running it.
1725
+ # This is the safe half of ExecuteCommand and the way to discover
1726
+ # which commands this build of the IDE actually exposes.
1727
+ 'command_id' {
1728
+ $name = [string]$req.name
1729
+ if ([string]::IsNullOrWhiteSpace($name)) { throw 'command_id requires "name"' }
1730
+ $app = Connect-App
1731
+ $cid = $app.GetIdOfCommand($name)
1732
+ $ok = $true
1733
+ $data = [ordered]@{ name = $name; command_id = $cid; known = ($cid -ne 0) }
1734
+ }
1735
+
1736
+ # Drive an IDE menu command by name - the programmatic equivalent of
1737
+ # clicking the menu, without synthesising any input.
1738
+ 'command' {
1739
+ $name = [string]$req.name
1740
+ if ([string]::IsNullOrWhiteSpace($name)) { throw 'command requires "name"' }
1741
+ if ($name -ne 'adeCmdBuildRebuildProject') { throw 'Only the offline Rebuild command is allowed; use the dedicated rebuild tool' }
1742
+ $app = Connect-App
1743
+ [void](Assert-StagedOpen $app 'command')
1744
+ $cid = $app.GetIdOfCommand($name)
1745
+ if ($cid -eq 0) { throw "unknown command '$name' (GetIdOfCommand returned 0)" }
1746
+ $app.ExecuteCommand($cid)
1747
+ $ok = $true
1748
+ $data = [ordered]@{ name = $name; command_id = $cid; executed = $true }
1749
+ }
1750
+
1751
+ 'feature_state' {
1752
+ $name = [string]$req.name
1753
+ if ([string]::IsNullOrWhiteSpace($name)) { throw 'feature_state requires "name"' }
1754
+ $app = Connect-App
1755
+ $ok = $true
1756
+ $data = [ordered]@{ name = $name; state = $app.GetFeatureState($name) }
1757
+ }
1758
+
1759
+ # Live variable model. This is the part that makes editing THROUGH the
1760
+ # IDE possible: reading variables from the running project avoids
1761
+ # hand-maintaining .VGR/.VB/PROJECT.TRE consistency entirely.
1762
+ # Optional "pou" narrows to one POU; omitted reads all of them.
1763
+ 'variable_snapshot' {
1764
+ $app=Connect-App
1765
+ [void](Assert-StagedOpen $app $verb)
1766
+ $requireSaved=if($null -eq $req.require_saved){$false}else{$req.require_saved}
1767
+ $data=Get-VariableSnapshot $app ([string]$req.pou) $requireSaved
1768
+ $ok=$true
1769
+ }
1770
+ 'library_snapshot' {
1771
+ $app=Connect-App
1772
+ [void](Assert-StagedOpen $app $verb)
1773
+ $modified=[bool]$app.ActiveProject.IsModified
1774
+ $libraries=@()
1775
+ for($i=1;$i -le $app.ActiveProject.Libraries.Count;$i++){
1776
+ $library=$app.ActiveProject.Libraries.Item($i)
1777
+ $libraries+=,[ordered]@{name=[string]$library.Name;full_name=[string]$library.FullName;logical_name=[string]$library.LogicalName}
1778
+ }
1779
+ if([bool]$app.ActiveProject.IsModified -ne $modified){throw 'Native library inspection changed modified state'}
1780
+ $data=[ordered]@{libraries=$libraries;is_modified=$modified;action_performed=$false}
1781
+ $ok=$true
1782
+ }
1783
+ 'structure_snapshot' {
1784
+ $app=Connect-App
1785
+ [void](Assert-StagedOpen $app $verb)
1786
+ $data=Get-NativeStructure $app
1787
+ $ok=$true
1788
+ }
1789
+ 'pou_package_snapshot' {
1790
+ $app=Connect-App
1791
+ [void](Assert-StagedOpen $app $verb)
1792
+ if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: package snapshot requires known saved state'}
1793
+ $data=Get-PouPackageState $app
1794
+ if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: package inspection changed modified state'}
1795
+ $ok=$true
1796
+ }
1797
+ 'pou_convert' {
1798
+ $app=Connect-App
1799
+ [void](Assert-StagedOpen $app $verb)
1800
+ $project=[IO.Path]::GetFullPath([string]$req.project)
1801
+ if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine ($project+'.mwt') -or $app.ActiveProject.IsModified -ne $false -or $app.ActiveProject.IsCompiled -ne $true){throw 'REFUSED: wrong, unsaved or uncompiled project'}
1802
+ $name=[string]$req.pou;$language=[string]$req.language
1803
+ if($req.conversion_reviewed -ne $true -or $name -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$' -or $language -notin @('FBD','LD')){throw 'REFUSED: reviewed conversion identity/language required'}
1804
+ $now=Get-PouPackageState $app
1805
+ if(($now | ConvertTo-Json -Depth 30 -Compress) -cne ($req.before_state | ConvertTo-Json -Depth 30 -Compress)){throw 'REFUSED: native conversion baseline changed'}
1806
+ foreach($property in $req.before_files.PSObject.Properties){
1807
+ $file=[IO.Path]::GetFullPath((Join-Path $project $property.Name))
1808
+ if(-not $file.StartsWith($project.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: conversion source outside project'}
1809
+ Assert-NoLinkedPath $file
1810
+ if((Get-MwSha256 $file) -ine [string]$property.Value){throw 'REFUSED: saved conversion baseline changed'}
1811
+ }
1812
+ $present=@($now.structure.pous | Where-Object {$_.name -ceq $name})
1813
+ $destination=if($language -eq 'FBD'){3}else{4}
1814
+ if($present.Count -ne 1 -or $present[0].read_only -ne $false -or $present[0].language -notin @(2,3,4) -or $present[0].language -eq $destination){throw 'REFUSED: exact writable POU in a different supported language required'}
1815
+ $converted=$app.ActiveProject.Pous.Item($name).Convert($destination)
1816
+ $convertedName=if($converted -is [string]){$converted}else{[string]$converted.Name}
1817
+ if($convertedName -cne $name){throw 'Native conversion returned a different identity; inspect state before another action'}
1818
+ $app.ActiveProject.Save()
1819
+ $data=[ordered]@{name=$convertedName;language=[int]$app.ActiveProject.Pous.Item($name).PouLanguage;saved=($app.ActiveProject.IsModified -eq $false);method='native_com_pou_convert'}
1820
+ $ok=$true
1821
+ }
1822
+ 'pou_package_mutate' {
1823
+ $app=Connect-App
1824
+ [void](Assert-StagedOpen $app $verb)
1825
+ $project=[IO.Path]::GetFullPath([string]$req.project)
1826
+ if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine ($project+'.mwt') -or $app.ActiveProject.IsModified -ne $false){throw 'REFUSED: wrong or unsaved project'}
1827
+ $name=[string]$req.pou;$operation=[string]$req.operation
1828
+ if($name -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$' -or $operation -notin @('export','import')){throw 'REFUSED: unsupported package operation/identity'}
1829
+ $now=Get-PouPackageState $app
1830
+ if(($now | ConvertTo-Json -Depth 30 -Compress) -cne ($req.before_state | ConvertTo-Json -Depth 30 -Compress)){throw 'REFUSED: native package baseline changed'}
1831
+ foreach($property in $req.before_files.PSObject.Properties){
1832
+ $file=[IO.Path]::GetFullPath((Join-Path $project $property.Name))
1833
+ if(-not $file.StartsWith($project.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: baseline outside project'}
1834
+ Assert-NoLinkedPath $file
1835
+ if((Get-MwSha256 $file) -ine [string]$property.Value){throw 'REFUSED: saved package baseline changed'}
1836
+ }
1837
+ $directory=[IO.Path]::GetFullPath([string]$req.package_directory)
1838
+ $parent=[IO.Path]::GetFullPath((Join-Path $script:WorkspaceRoot '.motionworks'))
1839
+ if([IO.Path]::GetDirectoryName($directory) -ine $parent -or [IO.Path]::GetFileName($directory) -notmatch '^pk-[a-f0-9]{8}$' -or $directory.Length -ge 128 -or $directory -match '[^\x20-\x7e]|["\r\n]'){throw 'REFUSED: unsupported private package directory'}
1840
+ Assert-NoLinkedPath $directory
1841
+ if(-not (Test-Path -LiteralPath $directory -PathType Container)){throw 'REFUSED: package directory missing'}
1842
+ $manifest=Get-PouPackageManifest $directory
1843
+ $present=@($now.structure.pous | Where-Object {$_.name -ieq $name})
1844
+ if($operation -eq 'export'){
1845
+ if($manifest.Count -ne 0 -or $present.Count -ne 1 -or $present[0].read_only){throw 'REFUSED: export requires empty destination and writable POU'}
1846
+ $command='ExportPou '+$name+' "'+$directory+'"'
1847
+ }else{
1848
+ if($present.Count -ne 0 -or (Test-Path -LiteralPath (Join-Path $project ('POE\'+$name)))){throw 'REFUSED: package import identity/directory already exists'}
1849
+ if($manifest.Count -ne $req.package_manifest.Count){throw 'REFUSED: native package file inventory changed'}
1850
+ foreach($expected in $req.package_manifest){
1851
+ $actual=@($manifest | Where-Object {$_.path -ceq $expected.path})
1852
+ if($actual.Count -ne 1 -or $actual[0].bytes -ne $expected.bytes -or $actual[0].sha256 -cne $expected.sha256){throw 'REFUSED: native package manifest changed'}
1853
+ }
1854
+ $command='ImportPou "'+$directory+'"'
1855
+ }
1856
+ $returnCode=$app.ExecuteDdeCommand($command)
1857
+ if([int]$returnCode -ne 0){throw ('Native POU '+$operation+' returned '+$returnCode+'; inspect retained state before another action')}
1858
+ if($operation -eq 'import'){$app.ActiveProject.Save()}
1859
+ $data=[ordered]@{return_code=[int]$returnCode;saved=($app.ActiveProject.IsModified -eq $false);is_modified=$app.ActiveProject.IsModified;method=('native_dde_'+$operation+'Pou')}
1860
+ $ok=$true
1861
+ }
1862
+ 'code_mutate' {
1863
+ $app=Connect-App
1864
+ [void](Assert-StagedOpen $app $verb)
1865
+ $project=[IO.Path]::GetFullPath([string]$req.project)
1866
+ if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine ($project+'.mwt') -or [bool]$app.ActiveProject.IsModified){throw 'REFUSED: wrong or unsaved project'}
1867
+ $name=[string]$req.pou;$worksheet=[string]$req.worksheet
1868
+ if($name -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$' -or $worksheet -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$'){throw 'REFUSED: unsupported native code identity'}
1869
+ $target=$app.ActiveProject.Pous.Item($name)
1870
+ $language=[string]$req.language
1871
+ $languages=@{ST=2;IL=1}
1872
+ if(-not $languages.ContainsKey($language) -or [bool]$target.ReadOnly -or [int]$target.PouLanguage -ne $languages[$language]){throw 'REFUSED: code import requires matching writable ST/IL POU'}
1873
+ $now=Get-NativeStructure $app
1874
+ if(($now | ConvertTo-Json -Depth 20 -Compress) -cne ($req.before_native | ConvertTo-Json -Depth 20 -Compress)){throw 'REFUSED: native baseline changed'}
1875
+ Assert-VariableBaseline $req.before_declarations.variables (Get-VariableRows (Get-VariableSheet $app $name))
1876
+ foreach($property in $req.before_files.PSObject.Properties){
1877
+ $file=[IO.Path]::GetFullPath((Join-Path $project $property.Name))
1878
+ if(-not $file.StartsWith($project.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: source outside project'}
1879
+ Assert-NoLinkedPath $file
1880
+ $algorithm=[Security.Cryptography.SHA256]::Create()
1881
+ try{$hash=[BitConverter]::ToString($algorithm.ComputeHash([IO.File]::ReadAllBytes($file))).Replace('-','')}finally{$algorithm.Dispose()}
1882
+ if($hash -ine [string]$property.Value){throw 'REFUSED: saved code baseline changed'}
1883
+ }
1884
+ $inputFile=[IO.Path]::GetFullPath([string]$req.code_import_path)
1885
+ $inputRoot=[IO.Path]::GetFullPath((Join-Path $script:WorkspaceRoot '.motionworks\native-input'))
1886
+ if(-not $inputFile.StartsWith($inputRoot.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase) -or $inputFile -match '["\r\n]'){throw 'REFUSED: invalid code input path'}
1887
+ Assert-NoLinkedPath $inputFile
1888
+ if([string]$req.input_text -match '[^\x09\x0a\x0d\x20-\x7e]' -or [IO.File]::ReadAllText($inputFile) -cne [string]$req.input_text){throw 'REFUSED: code input changed or unsupported encoding'}
1889
+ $command='ChangeCodeWS '+$name+' '+$language+' '+$worksheet+' "'+$inputFile+'"'
1890
+ $returnCode=$app.ExecuteDdeCommand($command)
1891
+ if([int]$returnCode -ne 0){throw ('Native code import returned '+$returnCode+'; inspect state before another action')}
1892
+ $app.ActiveProject.Save()
1893
+ $data=[ordered]@{saved=$true;is_modified=[bool]$app.ActiveProject.IsModified;snapshot=(Get-NativeStructure $app);return_code=[int]$returnCode;method='native_dde_ChangeCodeWS'}
1894
+ $ok=$true
1895
+ }
1896
+ 'structure_mutate' {
1897
+ $app=Connect-App
1898
+ [void](Assert-StagedOpen $app $verb)
1899
+ $project=[IO.Path]::GetFullPath([string]$req.project)
1900
+ if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine ($project+'.mwt')){throw 'REFUSED: wrong native project'}
1901
+ if([bool]$app.ActiveProject.IsModified){throw 'REFUSED: reconcile/save native edits'}
1902
+ $now=Get-NativeStructure $app
1903
+ if(($now | ConvertTo-Json -Depth 20 -Compress) -cne ($req.before_native | ConvertTo-Json -Depth 20 -Compress)){throw 'REFUSED: structural baseline changed'}
1904
+ foreach($property in $req.before_files.PSObject.Properties) {
1905
+ $file=[IO.Path]::GetFullPath((Join-Path $project $property.Name))
1906
+ if(-not $file.StartsWith($project.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: baseline source outside project'}
1907
+ Assert-NoLinkedPath $file
1908
+ if(-not (Test-Path -LiteralPath $file)){throw 'REFUSED: saved structural source missing'}
1909
+ $algorithm=[Security.Cryptography.SHA256]::Create()
1910
+ try {$hash=[BitConverter]::ToString($algorithm.ComputeHash([IO.File]::ReadAllBytes($file))).Replace('-','')}
1911
+ finally {$algorithm.Dispose()}
1912
+ if($hash -ine [string]$property.Value){throw 'REFUSED: saved structural source changed'}
1913
+ }
1914
+ $name=[string]$req.name;$op=[string]$req.operation
1915
+ $max=if($req.scope -eq 'task'){7}else{30}
1916
+ if($name -notmatch ('^[A-Za-z_][A-Za-z0-9_]{0,'+($max-1)+'}$')){throw 'REFUSED: invalid native name'}
1917
+ if($req.scope -eq 'pou') {
1918
+ $pous=$app.ActiveProject.Pous
1919
+ if($op -eq 'create') {
1920
+ if([bool]$pous.ReadOnlyAttribute){throw 'REFUSED: POU collection read-only'}
1921
+ $types=@{PROGRAM=7;FUNCTION_BLOCK=6;FUNCTION=24}
1922
+ $kind=if($req.pou_type){[string]$req.pou_type}else{'PROGRAM'}
1923
+ if(-not $types.ContainsKey($kind)){throw 'REFUSED: invalid POU type'}
1924
+ $languages=@{IL=1;ST=2;FBD=3;LD=4}
1925
+ $language=if($req.language){[string]$req.language}else{'ST'}
1926
+ if(-not $languages.ContainsKey($language)){throw 'REFUSED: invalid POU language'}
1927
+ $created=$pous.Create($name,[int]$types[$kind],[int]$languages[$language],[string]$req.return_type,'','')
1928
+ # New native variable storage is lazy. Initialize it as
1929
+ # part of this authorized creation, before its Save,
1930
+ # so a later read does not dirty the new empty POU.
1931
+ [void]$created.Variables.Count
1932
+ [void]$created.Variables.Groups.Count
1933
+ }else{
1934
+ $target=$pous.Item($name)
1935
+ if([bool]$target.ReadOnly){throw 'REFUSED: POU read-only'}
1936
+ if($op -in @('rename','copy') -and [string]$req.new_name -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$'){throw 'REFUSED: invalid new name'}
1937
+ if($op -eq 'copy'){[void]$target.Copy([string]$req.new_name)}
1938
+ elseif($op -eq 'rename') {
1939
+ if($req.references_reviewed -ne $true){throw 'REFUSED: rename reference review required'}
1940
+ $target.Name=[string]$req.new_name
1941
+ }elseif($op -eq 'delete') {
1942
+ if($req.user_approved -ne $true -or $req.references_reviewed -ne $true){throw 'REFUSED: deletion approval/review required'}
1943
+ $target.Delete()
1944
+ }else{throw 'Unknown POU operation'}
1945
+ }
1946
+ }elseif($req.scope -eq 'task') {
1947
+ $resource=$app.ActiveProject.GetObjectByLogicalName('Hardware/Configuration/Resource',10)
1948
+ if($op -eq 'create') {
1949
+ $kind=if($req.kind){[string]$req.kind}else{'CYCLIC'}
1950
+ [void]$resource.Tasks.Create($name,$kind)
1951
+ }else{
1952
+ $target=$resource.Tasks.Item($name)
1953
+ if($op -eq 'delete') {
1954
+ if($req.user_approved -ne $true -or $target.ProgramInstances.Count -ne 0){throw 'REFUSED: task deletion approval or empty task required'}
1955
+ $target.Delete()
1956
+ }elseif($op -eq 'assign') {
1957
+ $instance=if($req.instance){[string]$req.instance}else{[string]$req.pou}
1958
+ $pou=$app.ActiveProject.Pous.Item([string]$req.pou)
1959
+ if([int]$pou.PouType -ne 7){throw 'REFUSED: assignment requires PROGRAM'}
1960
+ [void]$target.ProgramInstances.Create($instance,[string]$pou.Name)
1961
+ }elseif($op -eq 'unassign') {
1962
+ if($req.user_approved -ne $true){throw 'REFUSED: unassignment approval required'}
1963
+ $target.ProgramInstances.Item([string]$req.instance).Delete()
1964
+ }elseif($op -eq 'edit') {
1965
+ $inputFile=[IO.Path]::GetFullPath([string]$req.settings_import_path)
1966
+ $inputRoot=Join-Path $script:WorkspaceRoot '.motionworks\native-input'
1967
+ if(-not $inputFile.StartsWith([IO.Path]::GetFullPath($inputRoot).TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: task input outside private workspace input directory'}
1968
+ Assert-NoLinkedPath $inputFile
1969
+ if([IO.File]::ReadAllText($inputFile) -cne [string]$req.settings_text){throw 'REFUSED: task input changed'}
1970
+ if([string]$req.settings_text -notmatch ('^TASK '+[regex]::Escape($name)+'\r?\n')){throw 'REFUSED: task input names another task'}
1971
+ $target.ImportSettingsFile($inputFile)
1972
+ }else{throw 'Unknown task operation'}
1973
+ }
1974
+ }else{throw 'Unknown native structural scope'}
1975
+ $app.ActiveProject.Save()
1976
+ $deadline=[DateTime]::UtcNow.AddSeconds(10)
1977
+ do {
1978
+ Start-Sleep -Milliseconds 250
1979
+ $after=Get-NativeStructure $app
1980
+ $matches=(($after | ConvertTo-Json -Depth 20 -Compress) -ceq ($req.expected_native | ConvertTo-Json -Depth 20 -Compress))
1981
+ }while(-not $matches -and [DateTime]::UtcNow -lt $deadline)
1982
+ $data=[ordered]@{operation=$op;saved=$true;snapshot=$after;is_modified=[bool]$app.ActiveProject.IsModified;method='native_com';native_matches_plan=$matches}
1983
+ $ok=$true
1984
+ }
1985
+ 'variable_group_snapshot' {
1986
+ $app=Connect-App;[void](Assert-StagedOpen $app $verb)
1987
+ if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: unsaved native group baseline'}
1988
+ $data=Get-VariableGroupSnapshot $app ([string]$req.pou)
1989
+ if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: group inspection changed modified state'};$ok=$true
1990
+ }
1991
+ 'variable_group_mutate' {
1992
+ $app=Connect-App;[void](Assert-StagedOpen $app $verb)
1993
+ $project=[IO.Path]::GetFullPath([string]$req.project)
1994
+ if($req.baseline_saved -ne $true -or [IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine ($project+'.mwt') -or $app.ActiveProject.IsModified -ne $false){throw 'REFUSED: wrong or unsaved project'}
1995
+ $pou=[string]$req.pou;$op=[string]$req.operation;$name=[string]$req.name;$destination=if($op -eq 'rename'){[string]$req.new_name}else{$name}
1996
+ if($op -notin @('create','rename','delete') -or [string]::IsNullOrWhiteSpace($name) -or $name.Length -gt 255 -or $name -match '[\x00-\x1f]'){throw 'REFUSED: invalid group operation/name'}
1997
+ if($op -ne 'delete' -and ([string]::IsNullOrWhiteSpace($destination) -or $destination -cne $destination.Trim() -or $destination.Length -gt 255 -or $destination -match '[\x00-\x1f]')){throw 'REFUSED: invalid new group name'}
1998
+ $now=Get-VariableGroupSnapshot $app $pou
1999
+ if($app.ActiveProject.IsModified -ne $false){throw 'REFUSED: native state became unsaved during inspection'}
2000
+ if(($now | ConvertTo-Json -Depth 30 -Compress) -cne ($req.before_state | ConvertTo-Json -Depth 30 -Compress)){throw 'REFUSED: native group baseline changed'}
2001
+ if($pou -and @($now.structure.pous | Where-Object {$_.name -ceq $pou -and $_.read_only -eq $false}).Count -ne 1){throw 'REFUSED: exact writable POU required'}
2002
+ foreach($property in $req.before_files.PSObject.Properties){
2003
+ $file=[IO.Path]::GetFullPath((Join-Path $project $property.Name))
2004
+ if(-not $file.StartsWith($project.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'REFUSED: group baseline outside project'}
2005
+ Assert-NoLinkedPath $file
2006
+ if((Get-MwSha256 $file) -ine [string]$property.Value){throw 'REFUSED: saved group baseline changed'}
2007
+ }
2008
+ $vars=Get-VariableSheet $app $pou;$target=$null;$matches=0;$collision=$false;$count=[int]$vars.Groups.Count
2009
+ for($i=1;$i -le $count;$i++){$g=$vars.Groups.Item($i);if([string]$g.Name -ceq $name){$target=$g;$matches++};if([string]$g.Name -ieq $destination -and ($op -eq 'create' -or [string]$g.Name -cne $name)){$collision=$true}}
2010
+ if($collision){throw 'REFUSED: group destination collision'}
2011
+ if($op -eq 'create'){
2012
+ if($matches){throw 'REFUSED: group already exists'}
2013
+ [void]$vars.Groups.Create($destination)
2014
+ }else{
2015
+ if($matches -ne 1 -or $target.ReadOnly -ne $false){throw 'REFUSED: exact writable group required'}
2016
+ if($op -eq 'rename'){if($name -ceq $destination){throw 'REFUSED: group already named'};$target.Name=$destination}
2017
+ else{if($req.user_approved -ne $true -or $target.Variables.Count -ne 0 -or $count -le 1){throw 'REFUSED: approved empty group with another remaining group required'};$target.Delete()}
2018
+ }
2019
+ $app.ActiveProject.Save()
2020
+ $data=[ordered]@{saved=($app.ActiveProject.IsModified -eq $false);is_modified=[bool]$app.ActiveProject.IsModified;operation=$op;method='native_com_group'};$ok=$true
2021
+ }
2022
+ 'variable_mutate' {
2023
+ $app=Connect-App
2024
+ [void](Assert-StagedOpen $app $verb)
2025
+ if([IO.Path]::GetFullPath($app.ActiveProject.FullName) -ine [IO.Path]::GetFullPath([string]$req.project+'.mwt')) {throw 'REFUSED: wrong native project'}
2026
+ if([bool]$app.ActiveProject.IsModified){throw 'REFUSED: reconcile/save unsaved native edits first'}
2027
+ $vars=Get-VariableSheet $app ([string]$req.pou)
2028
+ Assert-VariableBaseline $req.before_native (Get-VariableRows $vars)
2029
+ $op=[string]$req.operation; $decl=$req.declaration
2030
+ if($op -notin @('add','edit','delete')){throw 'Unknown native variable operation'}
2031
+ $flagMap=@{retain='Retain';pdd='PDD';opc='OPC';disabled='Disabled';not_on_plc='NotOnPlc';redundant='Redundant'}
2032
+ if($null -ne $req.flags) {
2033
+ if($req.flags -isnot [pscustomobject]){throw 'REFUSED: native flags must be an object'}
2034
+ if($op -eq 'delete'){throw 'REFUSED: flag changes require add/edit'}
2035
+ foreach($flag in $req.flags.PSObject.Properties) {
2036
+ if(-not $flagMap.ContainsKey($flag.Name) -or $flag.Value -isnot [bool]){throw 'REFUSED: unknown or non-boolean native variable flag'}
2037
+ }
2038
+ }
2039
+ $target=$null
2040
+ for($i=1;$i -le $vars.Count;$i++) {if([string]$vars.Item($i).Name -ieq [string]$req.name){$target=$vars.Item($i)}}
2041
+ if($op -ne 'add' -and -not $target){throw 'REFUSED: exact variable not found'}
2042
+ $groupName=if($op -eq 'delete'){[string]$target.Group.Name}else{[string]$decl.group}
2043
+ $group=$null
2044
+ for($i=1;$i -le $vars.Groups.Count;$i++){if([string]$vars.Groups.Item($i).Name -ceq $groupName){$group=$vars.Groups.Item($i)}}
2045
+ if(-not $group -or [bool]$group.ReadOnly){throw 'REFUSED: target variable group absent or read-only'}
2046
+ if($op -eq 'delete') {
2047
+ if($req.user_approved -ne $true -or $req.references_reviewed -ne $true){throw 'REFUSED: deletion approval and reference review required'}
2048
+ $target.Delete()
2049
+ } else {
2050
+ $sections=@{VAR=1;VAR_INPUT=2;VAR_OUTPUT=3;VAR_IN_OUT=4;VAR_EXTERNAL=5;VAR_GLOBAL=6}
2051
+ $block=$sections[[string]$decl.section]
2052
+ if(-not $block -or ($req.pou -and $block -eq 6) -or (-not $req.pou -and $block -ne 6)){throw 'REFUSED: declaration usage does not match native scope'}
2053
+ if([string]$decl.name -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,29}$'){throw 'REFUSED: invalid IEC name'}
2054
+ if($block -eq 5 -and ($decl.address -or $decl.initial_value)){throw 'REFUSED: external address/initializer forbidden'}
2055
+ for($i=1;$i -le $vars.Count;$i++) {
2056
+ if([string]$vars.Item($i).Name -ieq [string]$decl.name -and ($op -eq 'add' -or [string]$target.Name -ine [string]$decl.name)){throw 'REFUSED: duplicate declaration name'}
2057
+ }
2058
+ if($op -eq 'add') {
2059
+ $group.Variables.Create([string]$decl.name,[string]$decl.type,[int]$block,[string]$decl.description,[string]$decl.initial_value,[string]$decl.address,$false) | Out-Null
2060
+ for($i=1;$i -le $vars.Count;$i++){if([string]$vars.Item($i).Name -ceq [string]$decl.name){$target=$vars.Item($i)}}
2061
+ if(-not $target){throw 'Native created variable cannot be resolved; inspect before another action'}
2062
+ } else {
2063
+ if([string]$target.Group.Name -cne $groupName){throw 'REFUSED: native Variable.Group is read-only; use the observed worksheet move workflow and full verification'}
2064
+ if([string]$target.Name -ine [string]$decl.name -and $req.rename_reviewed -ne $true){throw 'REFUSED: rename reference review required'}
2065
+ $target.Name=[string]$decl.name
2066
+ $target.DataType=[string]$decl.type
2067
+ $target.BlockType=[int]$block
2068
+ $target.Comment=[string]$decl.description
2069
+ $target.InitialValue=[string]$decl.initial_value
2070
+ $target.IecAddress=[string]$decl.address
2071
+ }
2072
+ if($null -ne $req.flags) {
2073
+ foreach($flag in $req.flags.PSObject.Properties) {
2074
+ $nativeFlag=$flagMap[$flag.Name]
2075
+ $target.$nativeFlag=[bool]$flag.Value
2076
+ }
2077
+ }
2078
+ }
2079
+ $app.ActiveProject.Save()
2080
+ $data=[ordered]@{operation=$op;saved=$true;variables=(Get-VariableRows $vars);is_modified=[bool]$app.ActiveProject.IsModified;method='native_com'}
2081
+ $ok=$true
2082
+ }
2083
+ 'variables' {
2084
+ $app = Connect-App
2085
+ [void](Assert-StagedOpen $app $verb)
2086
+ $want = [string]$req.pou
2087
+ $pous = $app.ActiveProject.Pous
2088
+ $result = @()
2089
+ for ($i = 1; $i -le $pous.Count; $i++) {
2090
+ $p = $pous.Item($i)
2091
+ $pname = [string]$p.Name
2092
+ if (-not [string]::IsNullOrWhiteSpace($want) -and $pname -ne $want) { continue }
2093
+
2094
+ $items = @()
2095
+ try {
2096
+ $vs = $p.Variables
2097
+ foreach ($v in (Get-VariableRows $vs)) {
2098
+ $items += [ordered]@{
2099
+ name = $v.name
2100
+ data_type = $v.type
2101
+ initial_value = $v.initial_value
2102
+ iec_address = $v.address
2103
+ section = $v.section
2104
+ group = $v.group
2105
+ description = $v.description
2106
+ retain = $v.retain
2107
+ pdd = $v.pdd
2108
+ opc = $v.opc
2109
+ disabled = $v.disabled
2110
+ not_on_plc = $v.not_on_plc
2111
+ redundant = $v.redundant
2112
+ }
2113
+ }
2114
+ } catch { throw ('Native variable model incomplete: '+$_.Exception.Message) }
2115
+
2116
+ $groups = @()
2117
+ try {
2118
+ $gs = $p.Variables.Groups
2119
+ for ($g = 1; $g -le $gs.Count; $g++) {
2120
+ $grp = $gs.Item($g)
2121
+ $gv = @()
2122
+ try {
2123
+ $gvv = $grp.Variables
2124
+ for ($m = 1; $m -le $gvv.Count; $m++) { $gv += [string]$gvv.Item($m).Name }
2125
+ } catch { throw ('Native group members incomplete: '+$_.Exception.Message) }
2126
+ $groups += [ordered]@{ name = [string]$grp.Name; count = $gv.Count; variables = $gv; read_only = [bool]$grp.ReadOnly }
2127
+ }
2128
+ } catch { throw ('Native variable groups incomplete: '+$_.Exception.Message) }
2129
+
2130
+ $result += [ordered]@{ pou = $pname; count = $items.Count; variables = $items; groups = $groups }
2131
+ }
2132
+ $ok = $true
2133
+ $data = [ordered]@{ pous = $result }
2134
+ }
2135
+
2136
+ # Launch the interactive IDE deliberately with its verified wrapper.
2137
+ # Connect-App refuses implicit launch. Background bridge helpers stay
2138
+ # hidden; the IDE frame must be visible for native readiness/trial checks.
2139
+ 'start_ide' {
2140
+ $launchProject = [string]$req.path
2141
+ Assert-ProvenCopy $launchProject
2142
+ $digest = Get-MwSha256 $launchProject
2143
+ if (-not $req.wrapper_sha256 -or $digest -ne [string]$req.wrapper_sha256) {
2144
+ throw 'REFUSED: startup requires a verified workspace wrapper.'
2145
+ }
2146
+ $exe = [string]$req.exe
2147
+ if ([string]::IsNullOrWhiteSpace($exe)) {
2148
+ $exe = 'C:\Program Files (x86)\Yaskawa\MotionWorks IEC 3 Pro\Mwt.exe'
2149
+ }
2150
+ $already = [bool](Get-Process -Name Mwt -ErrorAction SilentlyContinue)
2151
+ $trialAnswered = $null
2152
+ $trialMethod = $null
2153
+ $trialSeen = $false
2154
+ if (-not $already) {
2155
+ if (-not (Test-Path $exe)) { throw "Mwt.exe not found at $exe" }
2156
+ Start-Process -FilePath $exe -ArgumentList ('"' + $launchProject + '"') -WindowStyle Normal | Out-Null
2157
+ }
2158
+ # A running process or blocked frame is not a ready IDE. Inspect
2159
+ # the licence owner before COM and never start a duplicate instance.
2160
+ $deadline = (Get-Date).AddSeconds(300)
2161
+ while ((Get-Date) -lt $deadline) {
2162
+ $pair = Get-TrialDialog
2163
+ if ($pair[0]) {
2164
+ if ($trialSeen) { throw 'Trial action was submitted but the dialog remains; no automatic retry or duplicate IDE launch' }
2165
+ $trialSeen = $true
2166
+ $answer = Answer-TrialDialog
2167
+ $trialAnswered = [bool]$answer.dismissed
2168
+ $trialMethod = [string]$answer.method
2169
+ if (-not $trialAnswered) { throw 'Use Trial was submitted but closure was not verified; inspect mw_ide_trial before further input' }
2170
+ }
2171
+ $readyWindow = Get-IdeWindow
2172
+ if ($readyWindow -and [MWW]::IsWindowEnabled($readyWindow)) { break }
2173
+ if ($already -and $readyWindow) {
2174
+ $blockingDialogs = @(Get-IdeDialogs | Where-Object { $_.enabled -and $_.buttons.Count -gt 0 })
2175
+ if ($blockingDialogs.Count -gt 0 -and (Get-IdeWindow) -eq $readyWindow -and -not [MWW]::IsWindowEnabled($readyWindow)) {
2176
+ $frameKey = '0x{0:X}' -f ([int64]$readyWindow)
2177
+ $dialogNames = ($blockingDialogs | ForEach-Object { "$($_.title) [$($_.handle)]" }) -join ', '
2178
+ throw "Existing IDE frame $frameKey is blocked by an observed modal: $dialogNames. Inspect mw_ide_state and resolve that exact dialog deliberately before another start request. No COM operation, dialog answer or duplicate IDE launch was attempted."
2179
+ }
2180
+ }
2181
+ Start-Sleep -Milliseconds 500
2182
+ }
2183
+ $w = Get-IdeWindow
2184
+ if (-not $w) {
2185
+ $pair = Get-TrialDialog
2186
+ if ($pair[0]) {
2187
+ throw 'Startup remains blocked by the licence dialog. Inspect mw_ide_state and use mw_ide_trial(attempt:true) for one exact native Use Trial action. Do not start another IDE or use a generic dialog answer.'
2188
+ }
2189
+ throw 'No visible IDE frame was observed within 300s. Inspect mw_ide_state and the existing Mwt process before any retry; observation timeout does not prove process exit. Do not start a duplicate IDE.'
2190
+ }
2191
+ if (-not [MWW]::IsWindowEnabled($w)) {
2192
+ throw 'The IDE frame remains disabled after startup observation. Inspect mw_ide_state for the owning modal; no COM project operation or duplicate launch was attempted.'
2193
+ }
2194
+ # Force a fresh connection: the previous one pointed at the old process.
2195
+ $script:App = $null
2196
+ $app = Connect-App
2197
+ # A bare Mwt.exe launch can restore the last project. Leave it open
2198
+ # until the user authorizes saving and closing that exact project.
2199
+ $dismissed = $null
2200
+ $foreign = $null
2201
+ $openPath = Get-OpenProjectPath $app
2202
+ Log "start_ide already=$already openPath=$openPath"
2203
+ if ($openPath -and -not (Test-SameProject $openPath $launchProject)) {
2204
+ $foreign = $openPath
2205
+ }
2206
+ if ($foreign) {
2207
+ throw "REFUSED: MotionWorks has '$foreign' open. Ask the user whether the agent may save and close that project, then call mw_ide_open with user_approved=true and expected_project='$foreign'. No project operation was performed."
2208
+ }
2209
+ $ok = $true
2210
+ $data = [ordered]@{
2211
+ already_running = $already
2212
+ ide_window = ("0x{0:X}" -f ([int64]$w))
2213
+ version = [string]$app.Version
2214
+ launched_exe = $exe
2215
+ trial_dialog = $trialSeen
2216
+ trial_answered = $trialAnswered
2217
+ dismissed_project = $dismissed
2218
+ foreign_project = $foreign
2219
+ }
2220
+ # Cached callers retain their existing closed output schema.
2221
+ if ($req.include_trial_method -eq $true) { $data.trial_method=$trialMethod }
2222
+ }
2223
+
2224
+ # What the IDE is ACTUALLY doing, including modal dialogs the COM API
2225
+ # cannot see. Call this after every IDE step.
2226
+ #
2227
+ # While a dialog is up, the automation API reports nothing useful:
2228
+ # IsProjectOpen() is false or throws and OpenProject fails. An agent
2229
+ # reading only COM concludes "no project open" or "the IDE has closed"
2230
+ # when the IDE is in fact waiting for a button. This is the antidote.
2231
+ 'ide_state' {
2232
+ $ok = $true
2233
+ $data = Get-IdeState
2234
+ # Opt in so a cached caller's closed result schema is unchanged.
2235
+ if ($req.include_window_state -eq $true) {
2236
+ $frame = Get-IdeWindow
2237
+ $frameKey = if ($frame) { '0x{0:X}' -f ([int64]$frame) } else { $null }
2238
+ if ($frameKey -cne $data.ide_window) { throw 'IDE frame changed during window-state observation; reobserve' }
2239
+ $data.ide_minimized = if ($frame) { [MWW]::IsIconic([IntPtr]$frame) } else { $null }
2240
+ $data.ide_visible = if ($frame) { [MWW]::IsWindowVisible([IntPtr]$frame) } else { $null }
2241
+ }
2242
+ }
2243
+
2244
+ # Answer a modal dialog the IDE is waiting on.
2245
+ #
2246
+ # These are standard Win32 dialogs, so a BM_CLICK to the button handle
2247
+ # is enough - no synthetic input, no focus games.
2248
+ 'answer_dialog' {
2249
+ $dialogs = Get-IdeDialogs
2250
+ if ($dialogs.Count -eq 0) {
2251
+ $ok = $true
2252
+ $data = [ordered]@{ answered = $false; reason = 'no modal dialog is up' }
2253
+ } else {
2254
+ $target = $null
2255
+ if ($req.handle) {
2256
+ $target = $dialogs | Where-Object { $_.handle -eq [string]$req.handle } | Select-Object -First 1
2257
+ }
2258
+ if (-not $target) { $target = $dialogs | Where-Object { $_.enabled } | Select-Object -First 1 }
2259
+ if (-not $target) { $target = $dialogs | Select-Object -First 1 }
2260
+
2261
+ $wantLabel = if ($req.button) { ([string]$req.button) -replace '&', '' } else { $null }
2262
+ $wantId = $req.button_id
2263
+
2264
+ $btn = $null
2265
+ if ($null -ne $wantId) {
2266
+ $btn = $target.buttons | Where-Object { $_.id -eq [int]$wantId } | Select-Object -First 1
2267
+ }
2268
+ if (-not $btn -and $wantLabel) {
2269
+ $btn = $target.buttons | Where-Object {
2270
+ $_.label -ieq $wantLabel -or $_.label -ilike "$wantLabel*"
2271
+ } | Select-Object -First 1
2272
+ }
2273
+ if (-not $btn -and $wantLabel -eq 'Yes') {
2274
+ # Win32 message boxes use IDYES=6 / IDNO=7 / IDOK=1 / IDCANCEL=2.
2275
+ $wellKnown = @{ Yes = 6; No = 7; OK = 1; Cancel = 2; Retry = 4; Ignore = 5; Abort = 3 }
2276
+ if ($wellKnown.ContainsKey($wantLabel)) {
2277
+ $btn = $target.buttons | Where-Object { $_.id -eq $wellKnown[$wantLabel] } | Select-Object -First 1
2278
+ }
2279
+ }
2280
+
2281
+ if (-not $btn) {
2282
+ $labels = ($target.buttons | ForEach-Object { $_.label }) -join ', '
2283
+ throw "no button matching '$wantLabel' on dialog $($target.handle); it offers: $labels"
2284
+ }
2285
+
2286
+ $bh = $script:dialogButtons["$($target.handle)|$($btn.id)"]
2287
+ if (-not $bh) { throw "button handle for '$($btn.label)' was not captured" }
2288
+
2289
+ [void][MWW]::SendMessage([IntPtr]$bh, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero) # BM_CLICK
2290
+ Start-Sleep -Milliseconds 600
2291
+
2292
+ $stayed = [MWW]::IsWindow([IntPtr]$script:dialogHwnds[$target.handle])
2293
+ $ok = $true
2294
+ $data = [ordered]@{
2295
+ answered = (-not $stayed)
2296
+ dialog = $target.handle
2297
+ pressed = $btn.label
2298
+ pressed_id = $btn.id
2299
+ dialog_closed = (-not $stayed)
2300
+ message = $target.message
2301
+ }
2302
+ }
2303
+ }
2304
+
2305
+ # this is how you tell "no IDE yet" apart from "the IDE is waiting for
2306
+ # a human to answer the licence dialog".
2307
+ 'trial_state' {
2308
+ $pair = Get-TrialDialog
2309
+ $d = $pair[0]; $b = $pair[1]
2310
+ $ideW = Get-IdeWindow
2311
+ $ok = $true
2312
+ $data = [ordered]@{
2313
+ dialog_present = [bool]$d
2314
+ dialog_hwnd = $(if ($d) { "0x{0:X}" -f ([int64]$d) } else { $null })
2315
+ use_trial_hwnd = $(if ($b) { "0x{0:X}" -f ([int64]$b) } else { $null })
2316
+ ide_window = $(if ($ideW) { "0x{0:X}" -f ([int64]$ideW) } else { $null })
2317
+ verifier_running = [bool](Get-Process -Name mwctVerify -ErrorAction SilentlyContinue)
2318
+ }
2319
+ }
2320
+
2321
+ # Try to answer the licence dialog with "Use Trial".
2322
+ #
2323
+ # Every strategy is VERIFIED by re-checking the window, and if none of
2324
+ # them close it this reports that honestly instead of claiming success.
2325
+ # The dialog is a .NET WinForms window in ANOTHER process; from an
2326
+ # automated process, clicking it is best-effort, not guaranteed.
2327
+ 'dismiss_trial' {
2328
+ $data = Answer-TrialDialog
2329
+ $ok = [bool]$data.dismissed
2330
+ if (-not $ok) { $err = 'Use Trial action was submitted but the dialog remains. No automatic retry was sent. Inspect the exact dialog before further action.' }
2331
+ }
2332
+
2333
+ # List the IDE's output windows (Build/Errors/Warnings/Infos/...).
2334
+ 'output_windows' {
2335
+ $app = Connect-App
2336
+ $ow = $app.OutputWindows
2337
+ $names = @()
2338
+ for ($i = 1; $i -le $ow.Count; $i++) {
2339
+ try { $names += [string]$ow.Item($i).Name } catch { }
2340
+ }
2341
+ $ok = $true
2342
+ $data = [ordered]@{ count = $names.Count; names = $names }
2343
+ }
2344
+
2345
+ # Bring an output window to the front.
2346
+ #
2347
+ # This is how compile ERRORS are read: the automation API exposes the
2348
+ # verdict (IsCompiled) but never the messages, and the output windows
2349
+ # have AddEntry/Clear/Activate but no read accessor. Activating the pane
2350
+ # and then screenshotting it is the only route to the error text.
2351
+ 'activate_output' {
2352
+ $name = [string]$req.name
2353
+ if ([string]::IsNullOrWhiteSpace($name)) { throw 'activate_output requires "name"' }
2354
+ $app = Connect-App
2355
+ $ow = $app.OutputWindows
2356
+ $hit = $null
2357
+ for ($i = 1; $i -le $ow.Count; $i++) {
2358
+ try {
2359
+ $w = $ow.Item($i)
2360
+ if ("$($w.Name)" -eq $name) { $w.Activate(); $hit = $w; break }
2361
+ } catch { }
2362
+ }
2363
+ if ($null -eq $hit) { throw "no output window named '$name'" }
2364
+ $ok = $true
2365
+ $data = [ordered]@{ name = $name; caption = [string]$hit.Caption; activated = $true }
2366
+ }
2367
+
2368
+ # Read an output pane AS TEXT - this is how the agent gets compiler messages.
2369
+ #
2370
+ # Activating the pane brings its list to the front, then the visible
2371
+ # SysListView32 with rows is read through MSAA. Verified against a real
2372
+ # failing build: it returns the messages verbatim, for example
2373
+ # "No matching global variable found for 'x:y' in resource 'Resource'!"
2374
+ # "Instance 'CalcSplineMatrix' is used more than once!"
2375
+ # which a screenshot could never give an agent that has to act on them.
2376
+ 'read_output' {
2377
+ $name = if ($req.pane) { [string]$req.pane } else { 'Errors' }
2378
+ $limit = if ($req.limit) { [int]$req.limit } else { 200 }
2379
+ if ($null -ne $req.limit -and ($req.limit -isnot [int] -and $req.limit -isnot [long] -or $req.limit -lt 1 -or $req.limit -gt 5000)) { throw 'Output limit must be an integer from 1 to 5000' }
2380
+ $app = Connect-App
2381
+ [void](Assert-StagedOpen $app 'read_output')
2382
+ $ideWin = Get-IdeWindow
2383
+ if (-not $ideWin) { throw 'Exact IDE frame unavailable; diagnostics unverified' }
2384
+ if ([MWW]::IsIconic([IntPtr]$ideWin)) {
2385
+ throw ('IDE is minimized (0x{0:X}); diagnostics unverified, no pane activation performed. Restore this exact fixture frame deliberately with the connected Windows tool, reobserve, then read diagnostics. Do not repeat this request while minimized or infer missing hidden-dock recovery.' -f ([int64]$ideWin))
2386
+ }
2387
+ # Activate first: a pane that is not showing has no visible list.
2388
+ $activated = $false
2389
+ $ow = $app.OutputWindows
2390
+ for ($i = 1; $i -le $ow.Count; $i++) {
2391
+ $w = $ow.Item($i)
2392
+ if ("$($w.Name)" -ceq $name) { $activated = $true; break }
2393
+ }
2394
+ if (-not $activated) { throw "No exact output pane '$name'; no diagnostic list was read" }
2395
+ $idePid = Get-IdePid
2396
+
2397
+ # Find the MESSAGE WINDOW first, then read only its lists.
2398
+ #
2399
+ # Picking "the visible list with the most rows" across the whole IDE is
2400
+ # wrong: the Edit Wizard owns a SysListView32 too, so activating a pane
2401
+ # that happens to be empty returned the Edit Wizard's contents instead.
2402
+ # Scoping to the Message Window excludes every other list in the app.
2403
+ $script:msgWin = $null
2404
+ $ideWin = Get-IdeWindow
2405
+ if ($ideWin) {
2406
+ # The Message Window is a CHILD of the IDE frame (an AfxControlBar120
2407
+ # dock pane), not a top-level window, so EnumWindows never sees it.
2408
+ $cbTop = [MWW+EnumWindowsProc]{
2409
+ param($h, $l)
2410
+ $title = [MWW]::ReadText($h)
2411
+ if ($title -eq 'Message Window' -or $title -eq 'Messages') { $script:msgWin = $h }
2412
+ return $true
2413
+ }
2414
+ [void][MWW]::EnumChildWindows([IntPtr]$ideWin, $cbTop, [IntPtr]::Zero)
2415
+ }
2416
+
2417
+ # Activate() can select a tab without exposing its hidden dock bar.
2418
+ # Recover exactly View > Message Window via the installed menu
2419
+ # resource, once, with no focus dependence, mouse or keyboard input.
2420
+ if ($script:msgWin -and -not [MWW]::IsWindowVisible([IntPtr]$script:msgWin)) {
2421
+ if (-not [MWW]::IsWindowEnabled([IntPtr]$ideWin)) { throw 'IDE is modal; Message Window recovery refused' }
2422
+ $executable = (Get-Process -Id $idePid -ErrorAction Stop).Path
2423
+ if (-not $executable -or -not [MWW]::HasMessageWindowCommand($executable)) { throw 'Exact installed View > Message Window command not verified; recovery refused' }
2424
+ $modifiedBefore = [bool]$app.ActiveProject.IsModified
2425
+ if (-not [MWW]::PostMessage([IntPtr]$ideWin, 0x0111, [IntPtr]36554, [IntPtr]::Zero)) { throw 'Message Window command not accepted' }
2426
+ $recoveryDeadline = (Get-Date).AddSeconds(4)
2427
+ while (-not [MWW]::IsWindowVisible([IntPtr]$script:msgWin) -and (Get-Date) -lt $recoveryDeadline) { Start-Sleep -Milliseconds 100 }
2428
+ if (-not [MWW]::IsWindowVisible([IntPtr]$script:msgWin)) { throw 'Message Window recovery unverified; do not repeat the toggle' }
2429
+ [void](Assert-StagedOpen $app 'read_output')
2430
+ if ([bool]$app.ActiveProject.IsModified -ne $modifiedBefore) { throw 'Project modified state changed during Message Window recovery' }
2431
+ Log 'read_output exposed hidden Message Window via verified native command 36554'
2432
+ }
2433
+ # Select the requested tab only after visibility recovery. This
2434
+ # keeps activation side effects from masking a hidden baseline.
2435
+ $w.Activate()
2436
+ Start-Sleep -Milliseconds 700
2437
+
2438
+ $script:best = $null
2439
+ $script:readableLists = @()
2440
+ $root = if ($script:msgWin) { [IntPtr]$script:msgWin } else { [IntPtr]::Zero }
2441
+ if ($root -ne [IntPtr]::Zero) {
2442
+ $cbList = [MWW+EnumWindowsProc]{
2443
+ param($k, $l2)
2444
+ $cls = New-Object System.Text.StringBuilder 64
2445
+ [void][MWW]::GetClassNameW($k, $cls, 64)
2446
+ if ($cls.ToString() -ne 'SysListView32') { return $true }
2447
+ if (-not [MWW]::IsWindowVisible($k)) { return $true }
2448
+ $rc = New-Object MWW+RECT
2449
+ if (-not [MWW]::GetWindowRect($k, [ref]$rc)) { return $true }
2450
+ # Inactive panes retain WS_VISIBLE but collapse to zero area.
2451
+ # Row count cannot identify the selected diagnostic pane.
2452
+ if ($rc.Right -le $rc.Left -or $rc.Bottom -le $rc.Top) { return $true }
2453
+ $script:readableLists += $k
2454
+ return $true
2455
+ }
2456
+ [void][MWW]::EnumChildWindows($root, $cbList, [IntPtr]::Zero)
2457
+ }
2458
+
2459
+ if ($script:readableLists.Count -ne 1) {
2460
+ throw "Cannot uniquely identify the activated '$name' pane list ($($script:readableLists.Count) candidates); diagnostics unverified"
2461
+ } else {
2462
+ $script:best = $script:readableLists[0]
2463
+ $acc = [MWW]::GetAccessible([IntPtr]$script:best)
2464
+ if ($null -eq $acc) { throw "Activated '$name' pane is not accessible; diagnostics unverified" }
2465
+ $lines = New-Object System.Collections.ArrayList
2466
+ if ($null -ne $acc) {
2467
+ $n = [MWW]::ListRowCount([IntPtr]$script:best)
2468
+ for ($i = 1; $i -le [Math]::Min($n, $limit); $i++) {
2469
+ $t = ''
2470
+ try { $t = [string]$acc.accName($i) } catch { }
2471
+ if (-not $t) { throw "Cannot read row $i of '$name'; diagnostics incomplete" }
2472
+ [void]$lines.Add($t)
2473
+ }
2474
+ }
2475
+ $ok = $true
2476
+ $data = [ordered]@{
2477
+ pane = $name
2478
+ count = $lines.Count
2479
+ lines = @($lines)
2480
+ note = $(if ($lines.Count -eq 0) { "the '$name' pane has no messages; inspect compile state separately" } else { $null })
2481
+ }
2482
+ }
2483
+ }
2484
+
2485
+ # Close the IDE so a file-level code write can proceed. The write engine
2486
+ # refuses while the IDE holds the project, because the IDE's cached state
2487
+ # would overwrite an external edit.
2488
+ #
2489
+ # The PID is taken from the WINDOW, not from a process-name lookup:
2490
+ # process enumeration is unreliable in this environment and omits Mwt
2491
+ # entirely, which would make this report success while nothing closed.
2492
+ 'close_ide' {
2493
+ if (-not (Get-IdeWindow)) {
2494
+ $ok = $true; $data = @{ closed = $true; window_remaining = $false }
2495
+ } else {
2496
+ $app = Connect-App
2497
+ $current = Get-OpenProjectPath $app
2498
+ Assert-CloseConsent $req $current 'closing the IDE'
2499
+ if ($current) { $app.ActiveProject.Save() }
2500
+ # WM_CLOSE permits native save/modal handling; never kill the process.
2501
+ [void][MWW]::PostMessage([IntPtr](Get-IdeWindow), 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
2502
+ $deadline = (Get-Date).AddSeconds(15)
2503
+ while ((Get-IdeWindow) -and (Get-Date) -lt $deadline) {
2504
+ if (@(Get-IdeDialogs).Count -gt 0) { break }
2505
+ Start-Sleep -Milliseconds 300
2506
+ }
2507
+ $still = [bool](Get-IdeWindow)
2508
+ $ok = -not $still
2509
+ $data = @{ closed = (-not $still); window_remaining = $still }
2510
+ if ($still) { $err = 'IDE close is pending; inspect ide_state and resolve the native dialog before editing' }
2511
+ if (-not $still) { $script:App = $null }
2512
+ }
2513
+ }
2514
+
2515
+ # Capture the IDE window's OWN content.
2516
+ #
2517
+ # CopyFromScreen copies a screen REGION, so whatever window happens to
2518
+ # be stacked over the IDE is what lands in the PNG - a "screenshot of
2519
+ # the IDE" that shows an unrelated application. PrintWindow asks the
2520
+ # window to render itself into a DC and is occlusion-independent, so
2521
+ # it is tried first; a blank bitmap (measured on some builds) falls
2522
+ # back to a screen copy taken with the IDE foregrounded.
2523
+ 'screenshot' {
2524
+ $priorDpi = [IntPtr]::Zero
2525
+ if ($req.capture_cursor -eq $true) {
2526
+ $priorDpi = [MWW]::SetThreadDpiAwarenessContext([IntPtr](-4))
2527
+ if ($priorDpi -eq [IntPtr]::Zero) { throw 'Physical-pixel cursor capture context unavailable' }
2528
+ }
2529
+ try {
2530
+ $out = [string]$req.path
2531
+ if ([string]::IsNullOrWhiteSpace($out)) { throw 'screenshot requires "path"' }
2532
+ $ideW = Get-IdeWindow
2533
+ if (-not $ideW) { throw 'no running MotionWorks IDE window to capture' }
2534
+ $ideW = [IntPtr]$ideW
2535
+
2536
+ # Observing must not focus the main frame and dismiss an owned
2537
+ # graphical inline editor. No activation or implicit restoration.
2538
+ if ([MWW]::IsIconic($ideW)) { throw 'IDE is minimized; capture would require a window-state change. Restore deliberately before observing again' }
2539
+ $focusBefore = @([MWW]::CaptureFocus())
2540
+ $cursorBefore = $null
2541
+ $cursorBytes = $null; $cursorPath = $null
2542
+ if ($req.capture_cursor -eq $true) { $cursorBefore = [MWW]::CaptureCursor() }
2543
+
2544
+ Add-Type -AssemblyName System.Drawing
2545
+ $rc = New-Object MWW+RECT
2546
+ if (-not [MWW]::GetWindowRect($ideW, [ref]$rc)) { throw 'GetWindowRect failed' }
2547
+ $w = $rc.Right - $rc.Left
2548
+ $h = $rc.Bottom - $rc.Top
2549
+ if ($w -le 0 -or $h -le 0) { throw "unusable window rect ${w}x${h}" }
2550
+
2551
+ $dir = Split-Path -Parent $out
2552
+ if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
2553
+
2554
+ # True when every sampled pixel has the same colour, i.e. the
2555
+ # capture produced a flat image rather than the window.
2556
+ function Test-Blank([System.Drawing.Bitmap]$b) {
2557
+ $first = $b.GetPixel(0, 0).ToArgb()
2558
+ for ($y = 1; $y -lt 6; $y++) {
2559
+ for ($x = 1; $x -lt 6; $x++) {
2560
+ $px = [int]($b.Width * $x / 6); $py = [int]($b.Height * $y / 6)
2561
+ if ($b.GetPixel($px, $py).ToArgb() -ne $first) { return $false }
2562
+ }
2563
+ }
2564
+ return $true
2565
+ }
2566
+
2567
+ $method = $null
2568
+ # Native GDI cursor drawing does not populate an ARGB bitmap's
2569
+ # alpha channel. RGB keeps a successfully drawn glyph visible
2570
+ # in the saved PNG rather than silently transparent.
2571
+ $bmp = if ($req.capture_cursor -eq $true) {
2572
+ New-Object System.Drawing.Bitmap $w, $h, ([System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
2573
+ } else { New-Object System.Drawing.Bitmap $w, $h }
2574
+ try {
2575
+ foreach ($flags in @(2, 0)) { # PW_RENDERFULLCONTENT, then plain
2576
+ if ($method) { break }
2577
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
2578
+ $hdc = $g.GetHdc()
2579
+ $rendered = $false
2580
+ try { $rendered = [MWW]::PrintWindow($ideW, $hdc, [uint32]$flags) }
2581
+ finally { $g.ReleaseHdc($hdc); $g.Dispose() }
2582
+ if ($rendered -and -not (Test-Blank $bmp)) { $method = "printwindow($flags)" }
2583
+ }
2584
+
2585
+ if (-not $method) {
2586
+ if ([MWW]::GetAncestor($focusBefore[0], 3) -ne $ideW) {
2587
+ throw 'Own-window rendering failed and the IDE is not the foreground owner; screen-region capture refused without changing focus'
2588
+ }
2589
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
2590
+ $g.CopyFromScreen($rc.Left, $rc.Top, 0, 0, (New-Object System.Drawing.Size $w, $h))
2591
+ $g.Dispose()
2592
+ $method = 'screen-region'
2593
+ }
2594
+ $cursorEvidence = $null
2595
+ if ($null -ne $cursorBefore) {
2596
+ $visible = ($cursorBefore[0] -band 1) -ne 0
2597
+ $overIde = $cursorBefore[4] -eq $ideW.ToInt64() -and $cursorBefore[2] -ge $rc.Left -and $cursorBefore[2] -lt $rc.Right -and $cursorBefore[3] -ge $rc.Top -and $cursorBefore[3] -lt $rc.Bottom
2598
+ $cursorEvidence = [ordered]@{ visible=$visible; over_ide=$overIde; rendered=$false; screen_x=[int]$cursorBefore[2]; screen_y=[int]$cursorBefore[3]; window_x=([int]$cursorBefore[2]-$rc.Left); window_y=([int]$cursorBefore[3]-$rc.Top) }
2599
+ if ($visible -and $overIde) {
2600
+ $g = [System.Drawing.Graphics]::FromImage($bmp); $hdc = $g.GetHdc()
2601
+ try { $hotspot = [MWW]::DrawCapturedCursor($hdc,$cursorBefore,$rc.Left,$rc.Top) }
2602
+ finally { $g.ReleaseHdc($hdc); $g.Dispose() }
2603
+ $cursorEvidence.rendered=$true
2604
+ $cursorEvidence.hotspot_x=$hotspot[0]; $cursorEvidence.hotspot_y=$hotspot[1]
2605
+ # Also show the same native glyph against light and dark
2606
+ # backgrounds: XOR/white cursors can vanish on the canvas.
2607
+ $cursorPath = [IO.Path]::ChangeExtension($out, 'cursor.png')
2608
+ $tileWidth = $hotspot[2]+16; $tileHeight = $hotspot[3]+16
2609
+ $cursorBmp = New-Object System.Drawing.Bitmap ($tileWidth*2), $tileHeight, ([System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
2610
+ $cg = [System.Drawing.Graphics]::FromImage($cursorBmp)
2611
+ try {
2612
+ $cg.Clear([System.Drawing.Color]::White)
2613
+ $cg.FillRectangle([System.Drawing.Brushes]::Black,$tileWidth,0,$tileWidth,$tileHeight)
2614
+ $chdc=$cg.GetHdc()
2615
+ try {
2616
+ [MWW]::DrawCapturedCursor($chdc,$cursorBefore,([int]$cursorBefore[2]-$hotspot[0]-8),([int]$cursorBefore[3]-$hotspot[1]-8)) | Out-Null
2617
+ [MWW]::DrawCapturedCursor($chdc,$cursorBefore,([int]$cursorBefore[2]-$hotspot[0]-8-$tileWidth),([int]$cursorBefore[3]-$hotspot[1]-8)) | Out-Null
2618
+ } finally { $cg.ReleaseHdc($chdc) }
2619
+ $visiblePixels=$false
2620
+ for ($cy=0;$cy -lt $tileHeight -and -not $visiblePixels;$cy++) {
2621
+ for ($cx=0;$cx -lt ($tileWidth*2);$cx++) {
2622
+ $expectedColor=if($cx -lt $tileWidth){[System.Drawing.Color]::White.ToArgb()}else{[System.Drawing.Color]::Black.ToArgb()}
2623
+ if($cursorBmp.GetPixel($cx,$cy).ToArgb() -ne $expectedColor){$visiblePixels=$true;break}
2624
+ }
2625
+ }
2626
+ $cursorEvidence.glyph_visible=$visiblePixels
2627
+ $stream=New-Object IO.MemoryStream
2628
+ try { $cursorBmp.Save($stream,[System.Drawing.Imaging.ImageFormat]::Png); $cursorBytes=$stream.ToArray() }
2629
+ finally { $stream.Dispose() }
2630
+ } finally { $cg.Dispose(); $cursorBmp.Dispose() }
2631
+ $cursorEvidence.path=$cursorPath
2632
+ }
2633
+ $cursorAfter = [MWW]::CaptureCursor()
2634
+ if (($cursorBefore -join ':') -ne ($cursorAfter -join ':')) {
2635
+ throw 'Cursor position, shape, visibility or owning window changed during capture; reobserve before input'
2636
+ }
2637
+ }
2638
+ $focusAfter = @([MWW]::CaptureFocus())
2639
+ $beforeKey = ($focusBefore | ForEach-Object { $_.ToInt64() }) -join ':'
2640
+ $afterKey = ($focusAfter | ForEach-Object { $_.ToInt64() }) -join ':'
2641
+ if ($beforeKey -ne $afterKey) { throw 'Foreground/active/focused window changed during capture; image acceptance unverified. Reobserve before input' }
2642
+ $bmp.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)
2643
+ if ($null -ne $cursorBytes) { [IO.File]::WriteAllBytes($cursorPath,$cursorBytes) }
2644
+ } finally { $bmp.Dispose() }
2645
+
2646
+ # Which route was taken is a diagnostic, so it goes to the log rather
2647
+ # than the response: the registered output schema for this tool is an
2648
+ # exact shape (additionalProperties: false) and a running DSH keeps
2649
+ # the schema it loaded, so an extra field fails output validation.
2650
+ Log "screenshot $out via $method (${w}x${h}); foreground/active/focus preserved $beforeKey"
2651
+
2652
+ $ok = $true
2653
+ $data = [ordered]@{ path = $out; width = $w; height = $h }
2654
+ if ($null -ne $cursorEvidence) { $data.cursor = $cursorEvidence }
2655
+ } finally {
2656
+ if ($priorDpi -ne [IntPtr]::Zero) { [MWW]::SetThreadDpiAwarenessContext($priorDpi) | Out-Null }
2657
+ }
2658
+ }
2659
+
2660
+ default {
2661
+ throw "unknown verb '$verb' (allowed: ping, status, start_ide, open, close_ide, trial_state, dismiss_trial, ide_state, answer_dialog, pous, variables, compile_state, make, build, patch, worksheet, datatypes, output_windows, activate_output, command_id, command, feature_state, screenshot, read_output, save, stop)"
2662
+ }
2663
+ }
2664
+
2665
+ if ($verb -in @('make', 'build', 'rebuild', 'patch', 'worksheet', 'datatypes')) {
2666
+ $app = Connect-App
2667
+ [void](Assert-StagedOpen $app $verb)
2668
+
2669
+ $compiledBefore = [bool]$app.ActiveProject.IsCompiled
2670
+ $sw = [Diagnostics.Stopwatch]::StartNew()
2671
+ $accepted = $false; $lastErr = $null
2672
+ # A compile is refused while the compiler is busy ("Operation not
2673
+ # possible while compiler is running."), so retry rather than sleep a
2674
+ # fixed time.
2675
+ while ($sw.Elapsed.TotalSeconds -lt 120) {
2676
+ try {
2677
+ if ($verb -eq 'rebuild') {
2678
+ $cid = [int]$app.GetIdOfCommand('adeCmdBuildRebuildProject')
2679
+ if ($cid -ne 36570) { throw 'This IDE does not expose the verified Rebuild command' }
2680
+ $window = Get-IdeWindow
2681
+ if (-not $window) { throw 'No IDE window for Rebuild' }
2682
+ $accepted = [MWW]::PostMessage([IntPtr]$window, 0x0111, [IntPtr]$cid, [IntPtr]::Zero)
2683
+ if (-not $accepted) { throw 'Rebuild menu request was not posted' }
2684
+ } else { $app.ActiveProject.Compile($mode); $accepted = $true }
2685
+ break
2686
+ }
2687
+ catch { $lastErr = $_.Exception.Message; Start-Sleep -Milliseconds 750 }
2688
+ }
2689
+ if (-not $accepted) { throw "Compile($mode) never accepted within 120s; last error: $lastErr" }
2690
+
2691
+ # A cached IsCompiled=true is not proof that this request completed.
2692
+ # Only a false-to-true transition observed for this invocation settles it.
2693
+ $isCompiled = $false
2694
+ $observedPending = -not $compiledBefore
2695
+ $alreadyUpToDate = $false
2696
+ $settle = [Diagnostics.Stopwatch]::StartNew()
2697
+ $swPoll = [Diagnostics.Stopwatch]::StartNew()
2698
+ while ($swPoll.Elapsed.TotalSeconds -lt 90) {
2699
+ Start-Sleep -Milliseconds 400
2700
+ try {
2701
+ $currentCompiled = [bool]$app.ActiveProject.IsCompiled
2702
+ if (-not $currentCompiled) { $observedPending = $true }
2703
+ if ($currentCompiled -and $observedPending) { $isCompiled = $true; break }
2704
+ # A project that was ALREADY compiled and records no modification has nothing
2705
+ # to compile, so IsCompiled never drops and the transition test above can never
2706
+ # be satisfied: a successful no-op Make waited the full 90 seconds and reported
2707
+ # "completion unverified" while the project was in fact compiled and unmodified.
2708
+ # Holding the condition for two seconds separates a genuine no-op from the
2709
+ # instant before a real compile flips the flag.
2710
+ if ($verb -eq 'make' -and $currentCompiled -and $compiledBefore -and -not [bool]$app.ActiveProject.IsModified) {
2711
+ if ($settle.Elapsed.TotalSeconds -ge 2) {
2712
+ $isCompiled = $true; $alreadyUpToDate = $true; break
2713
+ }
2714
+ } else { $settle.Restart() }
2715
+ } catch { }
2716
+ }
2717
+ $settled = $isCompiled
2718
+ $stalled = -not $settled
2719
+
2720
+ # Never restore disk files underneath the running IDE. An offline
2721
+ # transaction journal is the recovery source after a verified close.
2722
+
2723
+ $ok = $true
2724
+ $data = [ordered]@{
2725
+ mode = $verb
2726
+ compile_type = $mode
2727
+ accepted = $accepted
2728
+ settled = $settled
2729
+ stalled = $stalled
2730
+ is_compiled = $isCompiled
2731
+ is_modified = $(try { [bool]$app.ActiveProject.IsModified } catch { $null })
2732
+ elapsed_s = [Math]::Round($sw.Elapsed.TotalSeconds, 1)
2733
+ evidence_kind = $(if ($alreadyUpToDate) { 'already_up_to_date' } elseif ($isCompiled -and $observedPending) { 'observed_compile_transition' } else { 'completion_unverified' })
2734
+ fresh_compile = ($isCompiled -and $observedPending -and -not $alreadyUpToDate)
2735
+ }
2736
+ }
2737
+ }
2738
+ catch {
2739
+ $ok = $false
2740
+ $err = $_.Exception.Message
2741
+ # Before reporting a failure, find out whether the IDE is simply waiting for
2742
+ # a button press. The automation API goes quiet while a modal dialog is up,
2743
+ # so a bare failure reads as "the IDE has closed" - and the agent then
2744
+ # abandons a perfectly healthy IDE, or worse, relaunches it. Naming the
2745
+ # dialog here means EVERY failing tool call explains itself, with no
2746
+ # discipline required from the agent.
2747
+ try {
2748
+ $dlgs = @(Get-IdeDialogs)
2749
+ if ($dlgs.Count -gt 0) {
2750
+ $desc = ($dlgs | ForEach-Object {
2751
+ $lbl = ($_.buttons | ForEach-Object { $_.label }) -join ', '
2752
+ "dialog $($_.handle) says: `"$($_.message)`" [buttons: $lbl]"
2753
+ }) -join ' || '
2754
+ $err = "$err`n`nNOTE: the IDE is NOT closed. It is blocked by a modal dialog. $desc`nCall mw_ide_state to see it (if available), or mw_ide_dialog to answer it; otherwise answer it by hand and retry."
2755
+ }
2756
+ } catch { }
2757
+ }
2758
+
2759
+ Write-Result $id $verb $ok $data $err
2760
+ Remove-Item -Force $reqPath -ErrorAction SilentlyContinue
2761
+ Log "res id=$id ok=$ok err=$err"
2762
+ Start-Sleep -Milliseconds 50
2763
+ }