synabun 1.3.0 → 2.0.0-beta

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 (1153) hide show
  1. package/CHANGELOG.md +197 -662
  2. package/LICENSE-COMMERCIAL.md +33 -0
  3. package/NOTICE +17 -0
  4. package/README.md +175 -853
  5. package/THIRD-PARTY-LICENSES.md +131 -0
  6. package/benchmarks/browser-live-results.json +79 -0
  7. package/benchmarks/browser-v2-results.json +258 -0
  8. package/benchmarks/fixtures/browser/README.md +57 -0
  9. package/benchmarks/fixtures/browser/page-state.json +132 -0
  10. package/benchmarks/fixtures/browser/social.json +92 -0
  11. package/benchmarks/fixtures/browser/target.json +319 -0
  12. package/benchmarks/fixtures/desktop/README.md +58 -0
  13. package/benchmarks/fixtures/desktop/abstention.json +59 -0
  14. package/benchmarks/fixtures/desktop/adversarial.json +22 -0
  15. package/benchmarks/fixtures/desktop/forbidden.json +93 -0
  16. package/benchmarks/fixtures/desktop/generate.mjs +1023 -0
  17. package/benchmarks/fixtures/desktop/target.json +136 -0
  18. package/bin/synabun.js +30 -0
  19. package/docs/browser-v2.md +109 -0
  20. package/docs/hook-integrity.md +89 -0
  21. package/docs/judgments.md +182 -0
  22. package/docs/style-guide.md +246 -0
  23. package/docs/whatsapp.md +325 -0
  24. package/hooks/claude-code/post-plan.mjs +613 -391
  25. package/hooks/claude-code/post-remember.mjs +246 -122
  26. package/hooks/claude-code/pre-compact.mjs +171 -110
  27. package/hooks/claude-code/pre-task.mjs +133 -0
  28. package/hooks/claude-code/pre-websearch.mjs +15 -4
  29. package/hooks/claude-code/prompt-origin.mjs +94 -0
  30. package/hooks/claude-code/prompt-submit.mjs +1244 -861
  31. package/hooks/claude-code/session-start.mjs +114 -156
  32. package/hooks/claude-code/shared.mjs +1228 -368
  33. package/hooks/claude-code/state.mjs +167 -0
  34. package/hooks/claude-code/stop.mjs +515 -140
  35. package/hooks/claude-code/subagent-start.mjs +154 -0
  36. package/hooks/claude-code/subagent-stop.mjs +102 -0
  37. package/hooks/claude-code/task-gate.mjs +150 -0
  38. package/lib/claude-hooks.js +84 -0
  39. package/lib/claude-usage-audit.js +141 -0
  40. package/lib/client-config-repair.js +144 -0
  41. package/lib/data-home-migration.js +498 -0
  42. package/lib/first-launch-protection.js +240 -0
  43. package/lib/paths.d.ts +21 -0
  44. package/lib/paths.js +103 -0
  45. package/mcp-server/dist/config.d.ts +22 -0
  46. package/mcp-server/dist/config.d.ts.map +1 -0
  47. package/mcp-server/dist/config.js +109 -0
  48. package/mcp-server/dist/config.js.map +1 -0
  49. package/mcp-server/dist/http.d.ts +30 -0
  50. package/mcp-server/dist/http.d.ts.map +1 -0
  51. package/mcp-server/dist/http.js +276 -0
  52. package/mcp-server/dist/http.js.map +1 -0
  53. package/mcp-server/dist/index.d.ts +48 -0
  54. package/mcp-server/dist/index.d.ts.map +1 -0
  55. package/mcp-server/dist/index.js +538 -0
  56. package/mcp-server/dist/index.js.map +1 -0
  57. package/mcp-server/dist/preload-http.d.ts +3 -0
  58. package/mcp-server/dist/preload-http.d.ts.map +1 -0
  59. package/mcp-server/dist/preload-http.js +28 -0
  60. package/mcp-server/dist/preload-http.js.map +1 -0
  61. package/mcp-server/dist/preload.d.ts +3 -0
  62. package/mcp-server/dist/preload.d.ts.map +1 -0
  63. package/mcp-server/dist/preload.js +32 -0
  64. package/mcp-server/dist/preload.js.map +1 -0
  65. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts +19 -0
  66. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts.map +1 -0
  67. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js +403 -0
  68. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js.map +1 -0
  69. package/mcp-server/dist/scripts/setup-embeddings.d.ts +3 -0
  70. package/mcp-server/dist/scripts/setup-embeddings.d.ts.map +1 -0
  71. package/mcp-server/dist/scripts/setup-embeddings.js +15 -0
  72. package/mcp-server/dist/scripts/setup-embeddings.js.map +1 -0
  73. package/mcp-server/dist/services/assistant-judgments.d.ts +79 -0
  74. package/mcp-server/dist/services/assistant-judgments.d.ts.map +1 -0
  75. package/mcp-server/dist/services/assistant-judgments.js +128 -0
  76. package/mcp-server/dist/services/assistant-judgments.js.map +1 -0
  77. package/mcp-server/dist/services/browser-assist-gate.d.ts +314 -0
  78. package/mcp-server/dist/services/browser-assist-gate.d.ts.map +1 -0
  79. package/mcp-server/dist/services/browser-assist-gate.js +344 -0
  80. package/mcp-server/dist/services/browser-assist-gate.js.map +1 -0
  81. package/mcp-server/dist/services/browser-assist.d.ts +104 -0
  82. package/mcp-server/dist/services/browser-assist.d.ts.map +1 -0
  83. package/mcp-server/dist/services/browser-assist.js +282 -0
  84. package/mcp-server/dist/services/browser-assist.js.map +1 -0
  85. package/mcp-server/dist/services/browser-bench.d.ts +772 -0
  86. package/mcp-server/dist/services/browser-bench.d.ts.map +1 -0
  87. package/mcp-server/dist/services/browser-bench.js +424 -0
  88. package/mcp-server/dist/services/browser-bench.js.map +1 -0
  89. package/mcp-server/dist/services/browser-judgments.d.ts +196 -0
  90. package/mcp-server/dist/services/browser-judgments.d.ts.map +1 -0
  91. package/mcp-server/dist/services/browser-judgments.js +363 -0
  92. package/mcp-server/dist/services/browser-judgments.js.map +1 -0
  93. package/mcp-server/dist/services/browser-risk.d.ts +166 -0
  94. package/mcp-server/dist/services/browser-risk.d.ts.map +1 -0
  95. package/mcp-server/dist/services/browser-risk.js +458 -0
  96. package/mcp-server/dist/services/browser-risk.js.map +1 -0
  97. package/mcp-server/dist/services/categories.d.ts +61 -0
  98. package/mcp-server/dist/services/categories.d.ts.map +1 -0
  99. package/mcp-server/dist/services/categories.js +377 -0
  100. package/mcp-server/dist/services/categories.js.map +1 -0
  101. package/mcp-server/dist/services/codex-config-heal.d.ts +49 -0
  102. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -0
  103. package/mcp-server/dist/services/codex-config-heal.js +346 -0
  104. package/mcp-server/dist/services/codex-config-heal.js.map +1 -0
  105. package/mcp-server/dist/services/desktop-assist-gate.d.ts +198 -0
  106. package/mcp-server/dist/services/desktop-assist-gate.d.ts.map +1 -0
  107. package/mcp-server/dist/services/desktop-assist-gate.js +202 -0
  108. package/mcp-server/dist/services/desktop-assist-gate.js.map +1 -0
  109. package/mcp-server/dist/services/desktop-assist.d.ts +104 -0
  110. package/mcp-server/dist/services/desktop-assist.d.ts.map +1 -0
  111. package/mcp-server/dist/services/desktop-assist.js +266 -0
  112. package/mcp-server/dist/services/desktop-assist.js.map +1 -0
  113. package/mcp-server/dist/services/desktop-bench.d.ts +553 -0
  114. package/mcp-server/dist/services/desktop-bench.d.ts.map +1 -0
  115. package/mcp-server/dist/services/desktop-bench.js +311 -0
  116. package/mcp-server/dist/services/desktop-bench.js.map +1 -0
  117. package/mcp-server/dist/services/desktop-client.d.ts +23 -0
  118. package/mcp-server/dist/services/desktop-client.d.ts.map +1 -0
  119. package/mcp-server/dist/services/desktop-client.js +79 -0
  120. package/mcp-server/dist/services/desktop-client.js.map +1 -0
  121. package/mcp-server/dist/services/desktop-judgments.d.ts +125 -0
  122. package/mcp-server/dist/services/desktop-judgments.d.ts.map +1 -0
  123. package/mcp-server/dist/services/desktop-judgments.js +177 -0
  124. package/mcp-server/dist/services/desktop-judgments.js.map +1 -0
  125. package/mcp-server/dist/services/desktop-risk.d.ts +283 -0
  126. package/mcp-server/dist/services/desktop-risk.d.ts.map +1 -0
  127. package/mcp-server/dist/services/desktop-risk.js +605 -0
  128. package/mcp-server/dist/services/desktop-risk.js.map +1 -0
  129. package/mcp-server/dist/services/discord.d.ts +112 -0
  130. package/mcp-server/dist/services/discord.d.ts.map +1 -0
  131. package/mcp-server/dist/services/discord.js +477 -0
  132. package/mcp-server/dist/services/discord.js.map +1 -0
  133. package/mcp-server/dist/services/edit-stale.d.ts +81 -0
  134. package/mcp-server/dist/services/edit-stale.d.ts.map +1 -0
  135. package/mcp-server/dist/services/edit-stale.js +358 -0
  136. package/mcp-server/dist/services/edit-stale.js.map +1 -0
  137. package/mcp-server/dist/services/embedding-service.d.ts +16 -0
  138. package/mcp-server/dist/services/embedding-service.d.ts.map +1 -0
  139. package/mcp-server/dist/services/embedding-service.js +126 -0
  140. package/mcp-server/dist/services/embedding-service.js.map +1 -0
  141. package/mcp-server/dist/services/embedding-worker.d.ts +2 -0
  142. package/mcp-server/dist/services/embedding-worker.d.ts.map +1 -0
  143. package/mcp-server/dist/services/embedding-worker.js +52 -0
  144. package/mcp-server/dist/services/embedding-worker.js.map +1 -0
  145. package/mcp-server/dist/services/fb-regions.d.ts +71 -0
  146. package/mcp-server/dist/services/fb-regions.d.ts.map +1 -0
  147. package/mcp-server/dist/services/fb-regions.js +211 -0
  148. package/mcp-server/dist/services/fb-regions.js.map +1 -0
  149. package/mcp-server/dist/services/file-checksums.d.ts +22 -0
  150. package/mcp-server/dist/services/file-checksums.d.ts.map +1 -0
  151. package/mcp-server/dist/services/file-checksums.js +55 -0
  152. package/mcp-server/dist/services/file-checksums.js.map +1 -0
  153. package/mcp-server/dist/services/file-excerpt.d.ts +24 -0
  154. package/mcp-server/dist/services/file-excerpt.d.ts.map +1 -0
  155. package/mcp-server/dist/services/file-excerpt.js +65 -0
  156. package/mcp-server/dist/services/file-excerpt.js.map +1 -0
  157. package/mcp-server/dist/services/identity.d.ts +124 -0
  158. package/mcp-server/dist/services/identity.d.ts.map +1 -0
  159. package/mcp-server/dist/services/identity.js +241 -0
  160. package/mcp-server/dist/services/identity.js.map +1 -0
  161. package/mcp-server/dist/services/local-embeddings.d.ts +2 -0
  162. package/mcp-server/dist/services/local-embeddings.d.ts.map +1 -0
  163. package/mcp-server/dist/services/local-embeddings.js +3 -0
  164. package/mcp-server/dist/services/local-embeddings.js.map +1 -0
  165. package/mcp-server/dist/services/memory-client.d.ts +4 -0
  166. package/mcp-server/dist/services/memory-client.d.ts.map +1 -0
  167. package/mcp-server/dist/services/memory-client.js +27 -0
  168. package/mcp-server/dist/services/memory-client.js.map +1 -0
  169. package/mcp-server/dist/services/memory-judge-backfill.d.ts +135 -0
  170. package/mcp-server/dist/services/memory-judge-backfill.d.ts.map +1 -0
  171. package/mcp-server/dist/services/memory-judge-backfill.js +510 -0
  172. package/mcp-server/dist/services/memory-judge-backfill.js.map +1 -0
  173. package/mcp-server/dist/services/memory-judgments.d.ts +362 -0
  174. package/mcp-server/dist/services/memory-judgments.d.ts.map +1 -0
  175. package/mcp-server/dist/services/memory-judgments.js +674 -0
  176. package/mcp-server/dist/services/memory-judgments.js.map +1 -0
  177. package/mcp-server/dist/services/memory-maintenance.d.ts +108 -0
  178. package/mcp-server/dist/services/memory-maintenance.d.ts.map +1 -0
  179. package/mcp-server/dist/services/memory-maintenance.js +403 -0
  180. package/mcp-server/dist/services/memory-maintenance.js.map +1 -0
  181. package/mcp-server/dist/services/memory-retrieval.d.ts +66 -0
  182. package/mcp-server/dist/services/memory-retrieval.d.ts.map +1 -0
  183. package/mcp-server/dist/services/memory-retrieval.js +412 -0
  184. package/mcp-server/dist/services/memory-retrieval.js.map +1 -0
  185. package/mcp-server/dist/services/memory-schema.d.ts +16 -0
  186. package/mcp-server/dist/services/memory-schema.d.ts.map +1 -0
  187. package/mcp-server/dist/services/memory-schema.js +282 -0
  188. package/mcp-server/dist/services/memory-schema.js.map +1 -0
  189. package/mcp-server/dist/services/memory-sessions.d.ts +24 -0
  190. package/mcp-server/dist/services/memory-sessions.d.ts.map +1 -0
  191. package/mcp-server/dist/services/memory-sessions.js +110 -0
  192. package/mcp-server/dist/services/memory-sessions.js.map +1 -0
  193. package/mcp-server/dist/services/memory-triage.d.ts +60 -0
  194. package/mcp-server/dist/services/memory-triage.d.ts.map +1 -0
  195. package/mcp-server/dist/services/memory-triage.js +189 -0
  196. package/mcp-server/dist/services/memory-triage.js.map +1 -0
  197. package/mcp-server/dist/services/memory-verification.d.ts +24 -0
  198. package/mcp-server/dist/services/memory-verification.d.ts.map +1 -0
  199. package/mcp-server/dist/services/memory-verification.js +92 -0
  200. package/mcp-server/dist/services/memory-verification.js.map +1 -0
  201. package/mcp-server/dist/services/memory-writer.d.ts +26 -0
  202. package/mcp-server/dist/services/memory-writer.d.ts.map +1 -0
  203. package/mcp-server/dist/services/memory-writer.js +126 -0
  204. package/mcp-server/dist/services/memory-writer.js.map +1 -0
  205. package/mcp-server/dist/services/neural-interface.d.ts +439 -0
  206. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -0
  207. package/mcp-server/dist/services/neural-interface.js +1135 -0
  208. package/mcp-server/dist/services/neural-interface.js.map +1 -0
  209. package/mcp-server/dist/services/profiles.d.ts +120 -0
  210. package/mcp-server/dist/services/profiles.d.ts.map +1 -0
  211. package/mcp-server/dist/services/profiles.js +387 -0
  212. package/mcp-server/dist/services/profiles.js.map +1 -0
  213. package/mcp-server/dist/services/secret-gate.d.ts +29 -0
  214. package/mcp-server/dist/services/secret-gate.d.ts.map +1 -0
  215. package/mcp-server/dist/services/secret-gate.js +43 -0
  216. package/mcp-server/dist/services/secret-gate.js.map +1 -0
  217. package/mcp-server/dist/services/server-instructions.d.ts +10 -0
  218. package/mcp-server/dist/services/server-instructions.d.ts.map +1 -0
  219. package/mcp-server/dist/services/server-instructions.js +101 -0
  220. package/mcp-server/dist/services/server-instructions.js.map +1 -0
  221. package/mcp-server/dist/services/sqlite.d.ts +235 -0
  222. package/mcp-server/dist/services/sqlite.d.ts.map +1 -0
  223. package/mcp-server/dist/services/sqlite.js +1185 -0
  224. package/mcp-server/dist/services/sqlite.js.map +1 -0
  225. package/mcp-server/dist/services/typesafe-bench.d.ts +99 -0
  226. package/mcp-server/dist/services/typesafe-bench.d.ts.map +1 -0
  227. package/mcp-server/dist/services/typesafe-bench.js +218 -0
  228. package/mcp-server/dist/services/typesafe-bench.js.map +1 -0
  229. package/mcp-server/dist/services/typesafe-config.d.ts +342 -0
  230. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -0
  231. package/mcp-server/dist/services/typesafe-config.js +746 -0
  232. package/mcp-server/dist/services/typesafe-config.js.map +1 -0
  233. package/mcp-server/dist/services/typesafe.d.ts +213 -0
  234. package/mcp-server/dist/services/typesafe.d.ts.map +1 -0
  235. package/mcp-server/dist/services/typesafe.js +330 -0
  236. package/mcp-server/dist/services/typesafe.js.map +1 -0
  237. package/mcp-server/dist/services/vector-cache.d.ts +68 -0
  238. package/mcp-server/dist/services/vector-cache.d.ts.map +1 -0
  239. package/mcp-server/dist/services/vector-cache.js +271 -0
  240. package/mcp-server/dist/services/vector-cache.js.map +1 -0
  241. package/mcp-server/dist/services/whiteboard-geometry.d.ts +305 -0
  242. package/mcp-server/dist/services/whiteboard-geometry.d.ts.map +1 -0
  243. package/mcp-server/dist/services/whiteboard-geometry.js +666 -0
  244. package/mcp-server/dist/services/whiteboard-geometry.js.map +1 -0
  245. package/mcp-server/dist/tools/agents.d.ts +212 -0
  246. package/mcp-server/dist/tools/agents.d.ts.map +1 -0
  247. package/mcp-server/dist/tools/agents.js +407 -0
  248. package/mcp-server/dist/tools/agents.js.map +1 -0
  249. package/mcp-server/dist/tools/bluesky.d.ts +39 -0
  250. package/mcp-server/dist/tools/bluesky.d.ts.map +1 -0
  251. package/mcp-server/dist/tools/bluesky.js +1144 -0
  252. package/mcp-server/dist/tools/bluesky.js.map +1 -0
  253. package/mcp-server/dist/tools/browser-advanced.d.ts +58 -0
  254. package/mcp-server/dist/tools/browser-advanced.d.ts.map +1 -0
  255. package/mcp-server/dist/tools/browser-advanced.js +106 -0
  256. package/mcp-server/dist/tools/browser-advanced.js.map +1 -0
  257. package/mcp-server/dist/tools/browser-batch.d.ts +99 -0
  258. package/mcp-server/dist/tools/browser-batch.d.ts.map +1 -0
  259. package/mcp-server/dist/tools/browser-batch.js +187 -0
  260. package/mcp-server/dist/tools/browser-batch.js.map +1 -0
  261. package/mcp-server/dist/tools/browser-cheatsheet.d.ts +21 -0
  262. package/mcp-server/dist/tools/browser-cheatsheet.d.ts.map +1 -0
  263. package/mcp-server/dist/tools/browser-cheatsheet.js +144 -0
  264. package/mcp-server/dist/tools/browser-cheatsheet.js.map +1 -0
  265. package/mcp-server/dist/tools/browser-interact.d.ts +239 -0
  266. package/mcp-server/dist/tools/browser-interact.d.ts.map +1 -0
  267. package/mcp-server/dist/tools/browser-interact.js +300 -0
  268. package/mcp-server/dist/tools/browser-interact.js.map +1 -0
  269. package/mcp-server/dist/tools/browser-navigate.d.ts +90 -0
  270. package/mcp-server/dist/tools/browser-navigate.d.ts.map +1 -0
  271. package/mcp-server/dist/tools/browser-navigate.js +142 -0
  272. package/mcp-server/dist/tools/browser-navigate.js.map +1 -0
  273. package/mcp-server/dist/tools/browser-observe.d.ts +561 -0
  274. package/mcp-server/dist/tools/browser-observe.d.ts.map +1 -0
  275. package/mcp-server/dist/tools/browser-observe.js +2009 -0
  276. package/mcp-server/dist/tools/browser-observe.js.map +1 -0
  277. package/mcp-server/dist/tools/browser.d.ts +34 -0
  278. package/mcp-server/dist/tools/browser.d.ts.map +1 -0
  279. package/mcp-server/dist/tools/browser.js +112 -0
  280. package/mcp-server/dist/tools/browser.js.map +1 -0
  281. package/mcp-server/dist/tools/card-tools.d.ts +82 -0
  282. package/mcp-server/dist/tools/card-tools.d.ts.map +1 -0
  283. package/mcp-server/dist/tools/card-tools.js +137 -0
  284. package/mcp-server/dist/tools/card-tools.js.map +1 -0
  285. package/mcp-server/dist/tools/card.d.ts +8 -0
  286. package/mcp-server/dist/tools/card.d.ts.map +1 -0
  287. package/mcp-server/dist/tools/card.js +16 -0
  288. package/mcp-server/dist/tools/card.js.map +1 -0
  289. package/mcp-server/dist/tools/category.d.ts +32 -0
  290. package/mcp-server/dist/tools/category.d.ts.map +1 -0
  291. package/mcp-server/dist/tools/category.js +290 -0
  292. package/mcp-server/dist/tools/category.js.map +1 -0
  293. package/mcp-server/dist/tools/choice.d.ts +160 -0
  294. package/mcp-server/dist/tools/choice.d.ts.map +1 -0
  295. package/mcp-server/dist/tools/choice.js +80 -0
  296. package/mcp-server/dist/tools/choice.js.map +1 -0
  297. package/mcp-server/dist/tools/computer.d.ts +71 -0
  298. package/mcp-server/dist/tools/computer.d.ts.map +1 -0
  299. package/mcp-server/dist/tools/computer.js +174 -0
  300. package/mcp-server/dist/tools/computer.js.map +1 -0
  301. package/mcp-server/dist/tools/discord-channel.d.ts +44 -0
  302. package/mcp-server/dist/tools/discord-channel.d.ts.map +1 -0
  303. package/mcp-server/dist/tools/discord-channel.js +236 -0
  304. package/mcp-server/dist/tools/discord-channel.js.map +1 -0
  305. package/mcp-server/dist/tools/discord-guild.d.ts +20 -0
  306. package/mcp-server/dist/tools/discord-guild.d.ts.map +1 -0
  307. package/mcp-server/dist/tools/discord-guild.js +138 -0
  308. package/mcp-server/dist/tools/discord-guild.js.map +1 -0
  309. package/mcp-server/dist/tools/discord-member.d.ts +26 -0
  310. package/mcp-server/dist/tools/discord-member.d.ts.map +1 -0
  311. package/mcp-server/dist/tools/discord-member.js +122 -0
  312. package/mcp-server/dist/tools/discord-member.js.map +1 -0
  313. package/mcp-server/dist/tools/discord-message.d.ts +36 -0
  314. package/mcp-server/dist/tools/discord-message.d.ts.map +1 -0
  315. package/mcp-server/dist/tools/discord-message.js +184 -0
  316. package/mcp-server/dist/tools/discord-message.js.map +1 -0
  317. package/mcp-server/dist/tools/discord-onboarding.d.ts +32 -0
  318. package/mcp-server/dist/tools/discord-onboarding.d.ts.map +1 -0
  319. package/mcp-server/dist/tools/discord-onboarding.js +179 -0
  320. package/mcp-server/dist/tools/discord-onboarding.js.map +1 -0
  321. package/mcp-server/dist/tools/discord-role.d.ts +34 -0
  322. package/mcp-server/dist/tools/discord-role.d.ts.map +1 -0
  323. package/mcp-server/dist/tools/discord-role.js +178 -0
  324. package/mcp-server/dist/tools/discord-role.js.map +1 -0
  325. package/mcp-server/dist/tools/discord-thread.d.ts +28 -0
  326. package/mcp-server/dist/tools/discord-thread.d.ts.map +1 -0
  327. package/mcp-server/dist/tools/discord-thread.js +88 -0
  328. package/mcp-server/dist/tools/discord-thread.js.map +1 -0
  329. package/mcp-server/dist/tools/discord-webhook.d.ts +32 -0
  330. package/mcp-server/dist/tools/discord-webhook.d.ts.map +1 -0
  331. package/mcp-server/dist/tools/discord-webhook.js +154 -0
  332. package/mcp-server/dist/tools/discord-webhook.js.map +1 -0
  333. package/mcp-server/dist/tools/discord.d.ts +7 -0
  334. package/mcp-server/dist/tools/discord.d.ts.map +1 -0
  335. package/mcp-server/dist/tools/discord.js +25 -0
  336. package/mcp-server/dist/tools/discord.js.map +1 -0
  337. package/mcp-server/dist/tools/fb-groups.d.ts +98 -0
  338. package/mcp-server/dist/tools/fb-groups.d.ts.map +1 -0
  339. package/mcp-server/dist/tools/fb-groups.js +369 -0
  340. package/mcp-server/dist/tools/fb-groups.js.map +1 -0
  341. package/mcp-server/dist/tools/forget.d.ts +14 -0
  342. package/mcp-server/dist/tools/forget.d.ts.map +1 -0
  343. package/mcp-server/dist/tools/forget.js +26 -0
  344. package/mcp-server/dist/tools/forget.js.map +1 -0
  345. package/mcp-server/dist/tools/git-tools.d.ts +24 -0
  346. package/mcp-server/dist/tools/git-tools.d.ts.map +1 -0
  347. package/mcp-server/dist/tools/git-tools.js +107 -0
  348. package/mcp-server/dist/tools/git-tools.js.map +1 -0
  349. package/mcp-server/dist/tools/git.d.ts +7 -0
  350. package/mcp-server/dist/tools/git.d.ts.map +1 -0
  351. package/mcp-server/dist/tools/git.js +11 -0
  352. package/mcp-server/dist/tools/git.js.map +1 -0
  353. package/mcp-server/dist/tools/gsc/experience.d.ts +135 -0
  354. package/mcp-server/dist/tools/gsc/experience.d.ts.map +1 -0
  355. package/mcp-server/dist/tools/gsc/experience.js +351 -0
  356. package/mcp-server/dist/tools/gsc/experience.js.map +1 -0
  357. package/mcp-server/dist/tools/gsc/helpers.d.ts +78 -0
  358. package/mcp-server/dist/tools/gsc/helpers.d.ts.map +1 -0
  359. package/mcp-server/dist/tools/gsc/helpers.js +258 -0
  360. package/mcp-server/dist/tools/gsc/helpers.js.map +1 -0
  361. package/mcp-server/dist/tools/gsc/indexing.d.ts +121 -0
  362. package/mcp-server/dist/tools/gsc/indexing.d.ts.map +1 -0
  363. package/mcp-server/dist/tools/gsc/indexing.js +357 -0
  364. package/mcp-server/dist/tools/gsc/indexing.js.map +1 -0
  365. package/mcp-server/dist/tools/gsc/inspect.d.ts +78 -0
  366. package/mcp-server/dist/tools/gsc/inspect.d.ts.map +1 -0
  367. package/mcp-server/dist/tools/gsc/inspect.js +321 -0
  368. package/mcp-server/dist/tools/gsc/inspect.js.map +1 -0
  369. package/mcp-server/dist/tools/gsc/navigate.d.ts +44 -0
  370. package/mcp-server/dist/tools/gsc/navigate.d.ts.map +1 -0
  371. package/mcp-server/dist/tools/gsc/navigate.js +197 -0
  372. package/mcp-server/dist/tools/gsc/navigate.js.map +1 -0
  373. package/mcp-server/dist/tools/gsc/performance.d.ts +84 -0
  374. package/mcp-server/dist/tools/gsc/performance.d.ts.map +1 -0
  375. package/mcp-server/dist/tools/gsc/performance.js +274 -0
  376. package/mcp-server/dist/tools/gsc/performance.js.map +1 -0
  377. package/mcp-server/dist/tools/gsc/settings.d.ts +153 -0
  378. package/mcp-server/dist/tools/gsc/settings.d.ts.map +1 -0
  379. package/mcp-server/dist/tools/gsc/settings.js +425 -0
  380. package/mcp-server/dist/tools/gsc/settings.js.map +1 -0
  381. package/mcp-server/dist/tools/gsc.d.ts +7 -0
  382. package/mcp-server/dist/tools/gsc.d.ts.map +1 -0
  383. package/mcp-server/dist/tools/gsc.js +51 -0
  384. package/mcp-server/dist/tools/gsc.js.map +1 -0
  385. package/mcp-server/dist/tools/image-tools.d.ts +24 -0
  386. package/mcp-server/dist/tools/image-tools.d.ts.map +1 -0
  387. package/mcp-server/dist/tools/image-tools.js +80 -0
  388. package/mcp-server/dist/tools/image-tools.js.map +1 -0
  389. package/mcp-server/dist/tools/image.d.ts +10 -0
  390. package/mcp-server/dist/tools/image.d.ts.map +1 -0
  391. package/mcp-server/dist/tools/image.js +14 -0
  392. package/mcp-server/dist/tools/image.js.map +1 -0
  393. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +97 -0
  394. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +1 -0
  395. package/mcp-server/dist/tools/leonardo-browser-tools.js +228 -0
  396. package/mcp-server/dist/tools/leonardo-browser-tools.js.map +1 -0
  397. package/mcp-server/dist/tools/leonardo.d.ts +13 -0
  398. package/mcp-server/dist/tools/leonardo.d.ts.map +1 -0
  399. package/mcp-server/dist/tools/leonardo.js +21 -0
  400. package/mcp-server/dist/tools/leonardo.js.map +1 -0
  401. package/mcp-server/dist/tools/loop.d.ts +30 -0
  402. package/mcp-server/dist/tools/loop.d.ts.map +1 -0
  403. package/mcp-server/dist/tools/loop.js +401 -0
  404. package/mcp-server/dist/tools/loop.js.map +1 -0
  405. package/mcp-server/dist/tools/memories.d.ts +56 -0
  406. package/mcp-server/dist/tools/memories.d.ts.map +1 -0
  407. package/mcp-server/dist/tools/memories.js +171 -0
  408. package/mcp-server/dist/tools/memories.js.map +1 -0
  409. package/mcp-server/dist/tools/morelogin.d.ts +24 -0
  410. package/mcp-server/dist/tools/morelogin.d.ts.map +1 -0
  411. package/mcp-server/dist/tools/morelogin.js +79 -0
  412. package/mcp-server/dist/tools/morelogin.js.map +1 -0
  413. package/mcp-server/dist/tools/profile.d.ts +17 -0
  414. package/mcp-server/dist/tools/profile.d.ts.map +1 -0
  415. package/mcp-server/dist/tools/profile.js +114 -0
  416. package/mcp-server/dist/tools/profile.js.map +1 -0
  417. package/mcp-server/dist/tools/recall.d.ts +71 -0
  418. package/mcp-server/dist/tools/recall.d.ts.map +1 -0
  419. package/mcp-server/dist/tools/recall.js +147 -0
  420. package/mcp-server/dist/tools/recall.js.map +1 -0
  421. package/mcp-server/dist/tools/reflect.d.ts +56 -0
  422. package/mcp-server/dist/tools/reflect.d.ts.map +1 -0
  423. package/mcp-server/dist/tools/reflect.js +157 -0
  424. package/mcp-server/dist/tools/reflect.js.map +1 -0
  425. package/mcp-server/dist/tools/remember.d.ts +62 -0
  426. package/mcp-server/dist/tools/remember.d.ts.map +1 -0
  427. package/mcp-server/dist/tools/remember.js +201 -0
  428. package/mcp-server/dist/tools/remember.js.map +1 -0
  429. package/mcp-server/dist/tools/response.d.ts +23 -0
  430. package/mcp-server/dist/tools/response.d.ts.map +1 -0
  431. package/mcp-server/dist/tools/response.js +24 -0
  432. package/mcp-server/dist/tools/response.js.map +1 -0
  433. package/mcp-server/dist/tools/restore.d.ts +14 -0
  434. package/mcp-server/dist/tools/restore.d.ts.map +1 -0
  435. package/mcp-server/dist/tools/restore.js +23 -0
  436. package/mcp-server/dist/tools/restore.js.map +1 -0
  437. package/mcp-server/dist/tools/style-guide-tools.d.ts +51 -0
  438. package/mcp-server/dist/tools/style-guide-tools.d.ts.map +1 -0
  439. package/mcp-server/dist/tools/style-guide-tools.js +231 -0
  440. package/mcp-server/dist/tools/style-guide-tools.js.map +1 -0
  441. package/mcp-server/dist/tools/style-guide.d.ts +8 -0
  442. package/mcp-server/dist/tools/style-guide.d.ts.map +1 -0
  443. package/mcp-server/dist/tools/style-guide.js +12 -0
  444. package/mcp-server/dist/tools/style-guide.js.map +1 -0
  445. package/mcp-server/dist/tools/sync.d.ts +20 -0
  446. package/mcp-server/dist/tools/sync.d.ts.map +1 -0
  447. package/mcp-server/dist/tools/sync.js +156 -0
  448. package/mcp-server/dist/tools/sync.js.map +1 -0
  449. package/mcp-server/dist/tools/tictactoe-tools.d.ts +18 -0
  450. package/mcp-server/dist/tools/tictactoe-tools.d.ts.map +1 -0
  451. package/mcp-server/dist/tools/tictactoe-tools.js +78 -0
  452. package/mcp-server/dist/tools/tictactoe-tools.js.map +1 -0
  453. package/mcp-server/dist/tools/tictactoe.d.ts +7 -0
  454. package/mcp-server/dist/tools/tictactoe.d.ts.map +1 -0
  455. package/mcp-server/dist/tools/tictactoe.js +11 -0
  456. package/mcp-server/dist/tools/tictactoe.js.map +1 -0
  457. package/mcp-server/dist/tools/utils.d.ts +19 -0
  458. package/mcp-server/dist/tools/utils.d.ts.map +1 -0
  459. package/mcp-server/dist/tools/utils.js +54 -0
  460. package/mcp-server/dist/tools/utils.js.map +1 -0
  461. package/mcp-server/dist/tools/whiteboard-format.d.ts +88 -0
  462. package/mcp-server/dist/tools/whiteboard-format.d.ts.map +1 -0
  463. package/mcp-server/dist/tools/whiteboard-format.js +337 -0
  464. package/mcp-server/dist/tools/whiteboard-format.js.map +1 -0
  465. package/mcp-server/dist/tools/whiteboard-tools.d.ts +326 -0
  466. package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -0
  467. package/mcp-server/dist/tools/whiteboard-tools.js +222 -0
  468. package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -0
  469. package/mcp-server/dist/tools/whiteboard.d.ts +8 -0
  470. package/mcp-server/dist/tools/whiteboard.d.ts.map +1 -0
  471. package/mcp-server/dist/tools/whiteboard.js +16 -0
  472. package/mcp-server/dist/tools/whiteboard.js.map +1 -0
  473. package/mcp-server/dist/tools/youtube/analytics.d.ts +46 -0
  474. package/mcp-server/dist/tools/youtube/analytics.d.ts.map +1 -0
  475. package/mcp-server/dist/tools/youtube/analytics.js +83 -0
  476. package/mcp-server/dist/tools/youtube/analytics.js.map +1 -0
  477. package/mcp-server/dist/tools/youtube/dedup.d.ts +35 -0
  478. package/mcp-server/dist/tools/youtube/dedup.d.ts.map +1 -0
  479. package/mcp-server/dist/tools/youtube/dedup.js +56 -0
  480. package/mcp-server/dist/tools/youtube/dedup.js.map +1 -0
  481. package/mcp-server/dist/tools/youtube/download.d.ts +31 -0
  482. package/mcp-server/dist/tools/youtube/download.d.ts.map +1 -0
  483. package/mcp-server/dist/tools/youtube/download.js +30 -0
  484. package/mcp-server/dist/tools/youtube/download.js.map +1 -0
  485. package/mcp-server/dist/tools/youtube/helpers.d.ts +66 -0
  486. package/mcp-server/dist/tools/youtube/helpers.d.ts.map +1 -0
  487. package/mcp-server/dist/tools/youtube/helpers.js +143 -0
  488. package/mcp-server/dist/tools/youtube/helpers.js.map +1 -0
  489. package/mcp-server/dist/tools/youtube/media.d.ts +43 -0
  490. package/mcp-server/dist/tools/youtube/media.d.ts.map +1 -0
  491. package/mcp-server/dist/tools/youtube/media.js +97 -0
  492. package/mcp-server/dist/tools/youtube/media.js.map +1 -0
  493. package/mcp-server/dist/tools/youtube/metadata.d.ts +33 -0
  494. package/mcp-server/dist/tools/youtube/metadata.d.ts.map +1 -0
  495. package/mcp-server/dist/tools/youtube/metadata.js +106 -0
  496. package/mcp-server/dist/tools/youtube/metadata.js.map +1 -0
  497. package/mcp-server/dist/tools/youtube/navigate.d.ts +30 -0
  498. package/mcp-server/dist/tools/youtube/navigate.d.ts.map +1 -0
  499. package/mcp-server/dist/tools/youtube/navigate.js +71 -0
  500. package/mcp-server/dist/tools/youtube/navigate.js.map +1 -0
  501. package/mcp-server/dist/tools/youtube/playlist.d.ts +29 -0
  502. package/mcp-server/dist/tools/youtube/playlist.d.ts.map +1 -0
  503. package/mcp-server/dist/tools/youtube/playlist.js +128 -0
  504. package/mcp-server/dist/tools/youtube/playlist.js.map +1 -0
  505. package/mcp-server/dist/tools/youtube/schedule.d.ts +31 -0
  506. package/mcp-server/dist/tools/youtube/schedule.d.ts.map +1 -0
  507. package/mcp-server/dist/tools/youtube/schedule.js +147 -0
  508. package/mcp-server/dist/tools/youtube/schedule.js.map +1 -0
  509. package/mcp-server/dist/tools/youtube/source.d.ts +36 -0
  510. package/mcp-server/dist/tools/youtube/source.d.ts.map +1 -0
  511. package/mcp-server/dist/tools/youtube/source.js +85 -0
  512. package/mcp-server/dist/tools/youtube/source.js.map +1 -0
  513. package/mcp-server/dist/tools/youtube/upload.d.ts +53 -0
  514. package/mcp-server/dist/tools/youtube/upload.d.ts.map +1 -0
  515. package/mcp-server/dist/tools/youtube/upload.js +255 -0
  516. package/mcp-server/dist/tools/youtube/upload.js.map +1 -0
  517. package/mcp-server/dist/tools/youtube.d.ts +12 -0
  518. package/mcp-server/dist/tools/youtube.d.ts.map +1 -0
  519. package/mcp-server/dist/tools/youtube.js +38 -0
  520. package/mcp-server/dist/tools/youtube.js.map +1 -0
  521. package/mcp-server/dist/tui.d.ts +3 -0
  522. package/mcp-server/dist/tui.d.ts.map +1 -0
  523. package/mcp-server/dist/tui.js +806 -0
  524. package/mcp-server/dist/tui.js.map +1 -0
  525. package/mcp-server/dist/types.d.ts +77 -0
  526. package/mcp-server/dist/types.d.ts.map +1 -0
  527. package/mcp-server/dist/types.js +2 -0
  528. package/mcp-server/dist/types.js.map +1 -0
  529. package/mcp-server/package-lock.json +4798 -0
  530. package/mcp-server/package.json +55 -38
  531. package/mcp-server/run.mjs +12 -2
  532. package/mcp-server/src/config.ts +48 -3
  533. package/mcp-server/src/http.ts +296 -93
  534. package/mcp-server/src/index.ts +546 -203
  535. package/mcp-server/src/preload-http.ts +19 -18
  536. package/mcp-server/src/preload.ts +19 -18
  537. package/mcp-server/src/scripts/setup-embeddings.ts +9 -0
  538. package/mcp-server/src/services/assistant-judgments.ts +157 -0
  539. package/mcp-server/src/services/browser-assist-gate.ts +383 -0
  540. package/mcp-server/src/services/browser-assist.ts +267 -0
  541. package/mcp-server/src/services/browser-bench.ts +477 -0
  542. package/mcp-server/src/services/browser-judgments.ts +414 -0
  543. package/mcp-server/src/services/browser-risk.ts +484 -0
  544. package/mcp-server/src/services/categories.ts +36 -19
  545. package/mcp-server/src/services/codex-config-heal.ts +403 -0
  546. package/mcp-server/src/services/desktop-assist-gate.ts +251 -0
  547. package/mcp-server/src/services/desktop-assist.ts +308 -0
  548. package/mcp-server/src/services/desktop-bench.ts +372 -0
  549. package/mcp-server/src/services/desktop-client.ts +76 -0
  550. package/mcp-server/src/services/desktop-judgments.ts +218 -0
  551. package/mcp-server/src/services/desktop-risk.ts +743 -0
  552. package/mcp-server/src/services/edit-stale.ts +299 -0
  553. package/mcp-server/src/services/embedding-service.ts +108 -0
  554. package/mcp-server/src/services/embedding-worker.ts +46 -0
  555. package/mcp-server/src/services/fb-regions.ts +221 -0
  556. package/mcp-server/src/services/file-checksums.ts +26 -9
  557. package/mcp-server/src/services/file-excerpt.ts +59 -0
  558. package/mcp-server/src/services/identity.ts +291 -0
  559. package/mcp-server/src/services/local-embeddings.ts +2 -77
  560. package/mcp-server/src/services/memory-client.ts +23 -0
  561. package/mcp-server/src/services/memory-judge-backfill.ts +462 -0
  562. package/mcp-server/src/services/memory-judgments.ts +901 -0
  563. package/mcp-server/src/services/memory-maintenance.ts +360 -0
  564. package/mcp-server/src/services/memory-retrieval.ts +349 -0
  565. package/mcp-server/src/services/memory-schema.ts +280 -0
  566. package/mcp-server/src/services/memory-sessions.ts +87 -0
  567. package/mcp-server/src/services/memory-triage.ts +182 -0
  568. package/mcp-server/src/services/memory-verification.ts +68 -0
  569. package/mcp-server/src/services/memory-writer.ts +125 -0
  570. package/mcp-server/src/services/neural-interface.ts +1217 -73
  571. package/mcp-server/src/services/profiles.ts +428 -0
  572. package/mcp-server/src/services/secret-gate.ts +52 -0
  573. package/mcp-server/src/services/server-instructions.ts +122 -0
  574. package/mcp-server/src/services/sqlite.ts +1456 -823
  575. package/mcp-server/src/services/typesafe-bench.ts +247 -0
  576. package/mcp-server/src/services/typesafe-config.ts +790 -0
  577. package/mcp-server/src/services/typesafe.ts +390 -0
  578. package/mcp-server/src/services/vector-cache.ts +255 -0
  579. package/mcp-server/src/services/whiteboard-geometry.ts +711 -0
  580. package/mcp-server/src/tools/agents.ts +539 -0
  581. package/mcp-server/src/tools/bluesky.ts +1186 -0
  582. package/mcp-server/src/tools/browser-advanced.ts +15 -6
  583. package/mcp-server/src/tools/browser-batch.ts +178 -0
  584. package/mcp-server/src/tools/browser-cheatsheet.ts +155 -0
  585. package/mcp-server/src/tools/browser-interact.ts +362 -255
  586. package/mcp-server/src/tools/browser-navigate.ts +164 -77
  587. package/mcp-server/src/tools/browser-observe.ts +2222 -1330
  588. package/mcp-server/src/tools/browser.ts +181 -107
  589. package/mcp-server/src/tools/card.ts +7 -5
  590. package/mcp-server/src/tools/choice.ts +103 -0
  591. package/mcp-server/src/tools/computer.ts +190 -0
  592. package/mcp-server/src/tools/discord.ts +10 -23
  593. package/mcp-server/src/tools/fb-groups.ts +387 -0
  594. package/mcp-server/src/tools/forget.ts +1 -1
  595. package/mcp-server/src/tools/git.ts +3 -1
  596. package/mcp-server/src/tools/gsc/experience.ts +440 -0
  597. package/mcp-server/src/tools/gsc/helpers.ts +288 -0
  598. package/mcp-server/src/tools/gsc/indexing.ts +495 -0
  599. package/mcp-server/src/tools/gsc/inspect.ts +391 -0
  600. package/mcp-server/src/tools/gsc/navigate.ts +236 -0
  601. package/mcp-server/src/tools/gsc/performance.ts +352 -0
  602. package/mcp-server/src/tools/gsc/settings.ts +564 -0
  603. package/mcp-server/src/tools/gsc.ts +95 -0
  604. package/mcp-server/src/tools/image.ts +3 -1
  605. package/mcp-server/src/tools/leonardo-browser-tools.ts +47 -37
  606. package/mcp-server/src/tools/leonardo.ts +7 -5
  607. package/mcp-server/src/tools/loop.ts +118 -25
  608. package/mcp-server/src/tools/memories.ts +65 -5
  609. package/mcp-server/src/tools/morelogin.ts +81 -0
  610. package/mcp-server/src/tools/profile.ts +122 -0
  611. package/mcp-server/src/tools/recall.ts +105 -261
  612. package/mcp-server/src/tools/reflect.ts +171 -145
  613. package/mcp-server/src/tools/remember.ts +223 -115
  614. package/mcp-server/src/tools/style-guide-tools.ts +250 -0
  615. package/mcp-server/src/tools/style-guide.ts +16 -0
  616. package/mcp-server/src/tools/sync.ts +66 -9
  617. package/mcp-server/src/tools/tictactoe.ts +3 -1
  618. package/mcp-server/src/tools/utils.ts +22 -0
  619. package/mcp-server/src/tools/whiteboard-format.ts +394 -0
  620. package/mcp-server/src/tools/whiteboard-tools.ts +191 -183
  621. package/mcp-server/src/tools/whiteboard.ts +7 -5
  622. package/mcp-server/src/tools/youtube/analytics.ts +106 -0
  623. package/mcp-server/src/tools/youtube/dedup.ts +57 -0
  624. package/mcp-server/src/tools/youtube/download.ts +41 -0
  625. package/mcp-server/src/tools/youtube/helpers.ts +183 -0
  626. package/mcp-server/src/tools/youtube/media.ts +109 -0
  627. package/mcp-server/src/tools/youtube/metadata.ts +112 -0
  628. package/mcp-server/src/tools/youtube/navigate.ts +81 -0
  629. package/mcp-server/src/tools/youtube/playlist.ts +134 -0
  630. package/mcp-server/src/tools/youtube/schedule.ts +158 -0
  631. package/mcp-server/src/tools/youtube/source.ts +101 -0
  632. package/mcp-server/src/tools/youtube/upload.ts +296 -0
  633. package/mcp-server/src/tools/youtube.ts +47 -0
  634. package/mcp-server/src/tui.ts +8 -9
  635. package/neural-interface/games/TicTacToe/1.svg +1 -0
  636. package/neural-interface/games/TicTacToe/2.svg +1 -0
  637. package/neural-interface/games/TicTacToe/3.svg +1 -0
  638. package/neural-interface/games/TicTacToe/4.svg +1 -0
  639. package/neural-interface/games/TicTacToe/5.svg +1 -0
  640. package/neural-interface/games/TicTacToe/6.svg +1 -0
  641. package/neural-interface/games/TicTacToe/7.svg +1 -0
  642. package/neural-interface/games/TicTacToe/8.svg +1 -0
  643. package/neural-interface/games/TicTacToe/9.svg +1 -0
  644. package/neural-interface/games/TicTacToe/Board.svg +1 -0
  645. package/neural-interface/games/TicTacToe/Circle.svg +1 -0
  646. package/neural-interface/games/TicTacToe/Cross.svg +1 -0
  647. package/neural-interface/i18n/en.json +6983 -0
  648. package/neural-interface/i18n/pt-BR.json +6987 -0
  649. package/neural-interface/lib/assistant-api.js +1005 -0
  650. package/neural-interface/lib/assistant-attachments.js +121 -0
  651. package/neural-interface/lib/assistant-brains/claude.js +207 -0
  652. package/neural-interface/lib/assistant-brains/codex-browser-policy-hook.mjs +43 -0
  653. package/neural-interface/lib/assistant-brains/codex-route-gate-hook.mjs +56 -0
  654. package/neural-interface/lib/assistant-brains/codex.js +213 -0
  655. package/neural-interface/lib/assistant-brains/opencode-browser-policy.js +47 -0
  656. package/neural-interface/lib/assistant-brains/opencode-route-gate.js +62 -0
  657. package/neural-interface/lib/assistant-brains/opencode.js +320 -0
  658. package/neural-interface/lib/assistant-budget.js +318 -0
  659. package/neural-interface/lib/assistant-catalog.js +731 -0
  660. package/neural-interface/lib/assistant-clarify.js +664 -0
  661. package/neural-interface/lib/assistant-config.js +232 -0
  662. package/neural-interface/lib/assistant-dispatch.js +2744 -0
  663. package/neural-interface/lib/assistant-envelope.js +579 -0
  664. package/neural-interface/lib/assistant-evidence.js +236 -0
  665. package/neural-interface/lib/assistant-hidden-models.js +272 -0
  666. package/neural-interface/lib/assistant-jev.js +117 -0
  667. package/neural-interface/lib/assistant-judgments.js +111 -0
  668. package/neural-interface/lib/assistant-media.js +335 -0
  669. package/neural-interface/lib/assistant-memory.js +398 -0
  670. package/neural-interface/lib/assistant-persona.js +444 -0
  671. package/neural-interface/lib/assistant-plan-permissions.js +448 -0
  672. package/neural-interface/lib/assistant-playbooks/design.md +87 -0
  673. package/neural-interface/lib/assistant-playbooks.js +72 -0
  674. package/neural-interface/lib/assistant-pricing.js +159 -0
  675. package/neural-interface/lib/assistant-route-gate-codex.js +163 -0
  676. package/neural-interface/lib/assistant-route-gate.js +212 -0
  677. package/neural-interface/lib/assistant-router.js +1324 -0
  678. package/neural-interface/lib/assistant-runtime.js +3337 -0
  679. package/neural-interface/lib/assistant-task-prompt.js +472 -0
  680. package/neural-interface/lib/assistant-usage.js +1688 -0
  681. package/neural-interface/lib/augmented-path.js +50 -0
  682. package/neural-interface/lib/automation-storage-migration.js +54 -0
  683. package/neural-interface/lib/backup-service.js +851 -0
  684. package/neural-interface/lib/backup-zip-reader.js +262 -0
  685. package/neural-interface/lib/browser-assist-api.js +41 -0
  686. package/neural-interface/lib/browser-console.js +176 -0
  687. package/neural-interface/lib/browser-execution.js +241 -0
  688. package/neural-interface/lib/browser-extraction.js +191 -0
  689. package/neural-interface/lib/browser-screenshot.js +399 -0
  690. package/neural-interface/lib/browser-semantic-context.js +639 -0
  691. package/neural-interface/lib/browser-snapshots.js +192 -0
  692. package/neural-interface/lib/browser-tab-recovery.js +119 -0
  693. package/neural-interface/lib/browser-targets.js +70 -0
  694. package/neural-interface/lib/browser-tool-policy.js +926 -0
  695. package/neural-interface/lib/claude-accounts.js +230 -0
  696. package/neural-interface/lib/claude-agent-bridge.js +3296 -0
  697. package/neural-interface/lib/claude-bypass-policy.js +99 -0
  698. package/neural-interface/lib/claude-history.js +429 -0
  699. package/neural-interface/lib/claude-model-catalog.js +483 -0
  700. package/neural-interface/lib/claude-panel-requests.js +165 -0
  701. package/neural-interface/lib/claude-panel-session.js +272 -0
  702. package/neural-interface/lib/claude-permission-rules.js +153 -0
  703. package/neural-interface/lib/claude-session-cleanup.js +71 -0
  704. package/neural-interface/lib/claude-session-list.js +82 -0
  705. package/neural-interface/lib/claude-session-ops.js +140 -0
  706. package/neural-interface/lib/claude-settings-view.js +51 -0
  707. package/neural-interface/lib/claude-skin-connect.js +79 -0
  708. package/neural-interface/lib/claude-stall-policy.js +7 -0
  709. package/neural-interface/lib/claude-temporary.js +435 -0
  710. package/neural-interface/lib/cli-update-planner.js +312 -0
  711. package/neural-interface/lib/codex-capabilities.d.ts +41 -0
  712. package/neural-interface/lib/codex-capabilities.js +148 -0
  713. package/neural-interface/lib/codex-collaboration-mode.js +17 -0
  714. package/neural-interface/lib/codex-compat-rpc.js +241 -0
  715. package/neural-interface/lib/codex-mcp-approvals.js +56 -0
  716. package/neural-interface/lib/codex-mcp-apps.js +47 -0
  717. package/neural-interface/lib/codex-model-context.js +202 -0
  718. package/neural-interface/lib/codex-plan-store.js +71 -0
  719. package/neural-interface/lib/codex-request-journal.js +242 -0
  720. package/neural-interface/lib/codex-runtime-path.js +213 -0
  721. package/neural-interface/lib/codex-session-catalog.js +231 -0
  722. package/neural-interface/lib/codex-session-settings.js +46 -0
  723. package/neural-interface/lib/codex-sidepanel-permissions.js +110 -0
  724. package/neural-interface/lib/codex-thread-history.js +165 -0
  725. package/neural-interface/lib/codex-user-verification.js +34 -0
  726. package/neural-interface/lib/codex-writer-lifecycle.js +152 -0
  727. package/neural-interface/lib/confined-fs.js +256 -0
  728. package/neural-interface/lib/db.js +922 -0
  729. package/neural-interface/lib/design-md.js +9 -0
  730. package/neural-interface/lib/desktop/api.js +92 -0
  731. package/neural-interface/lib/desktop/audit.js +62 -0
  732. package/neural-interface/lib/desktop/build.js +423 -0
  733. package/neural-interface/lib/desktop/config.js +606 -0
  734. package/neural-interface/lib/desktop/coords.js +76 -0
  735. package/neural-interface/lib/desktop/fake-helper.js +1192 -0
  736. package/neural-interface/lib/desktop/frames.js +77 -0
  737. package/neural-interface/lib/desktop/grants.js +84 -0
  738. package/neural-interface/lib/desktop/guards.js +170 -0
  739. package/neural-interface/lib/desktop/helper/SynabunDesktop.swift +3056 -0
  740. package/neural-interface/lib/desktop/lease.js +51 -0
  741. package/neural-interface/lib/desktop/manager.js +545 -0
  742. package/neural-interface/lib/desktop/press-contexts.js +103 -0
  743. package/neural-interface/lib/desktop/protocol.js +495 -0
  744. package/neural-interface/lib/desktop/service.js +1202 -0
  745. package/neural-interface/lib/effort-levels.js +148 -0
  746. package/neural-interface/lib/event-loop-monitor.js +67 -0
  747. package/neural-interface/lib/exec-loop-command.js +209 -0
  748. package/neural-interface/lib/folder-picker.ps1 +77 -0
  749. package/neural-interface/lib/http-guards.js +287 -0
  750. package/neural-interface/lib/jsonl-lines.js +94 -0
  751. package/neural-interface/lib/loop-logger.js +127 -0
  752. package/neural-interface/lib/mcp-client-config.js +133 -0
  753. package/neural-interface/lib/mcp-installer.js +487 -0
  754. package/neural-interface/lib/mcp-profile-refresh-coordinator.js +78 -0
  755. package/neural-interface/lib/mcp-profile-servers.js +120 -0
  756. package/neural-interface/lib/memo-cache.js +84 -0
  757. package/neural-interface/lib/memory-api.js +758 -0
  758. package/neural-interface/lib/memory-map-api.js +1150 -0
  759. package/neural-interface/lib/memory-map-layout.js +872 -0
  760. package/neural-interface/lib/memory-map-worker.js +165 -0
  761. package/neural-interface/lib/memory-session-capture.js +81 -0
  762. package/neural-interface/lib/memory-timers.js +62 -0
  763. package/neural-interface/lib/morelogin.js +301 -0
  764. package/neural-interface/lib/native-binary-runtime.js +650 -0
  765. package/neural-interface/lib/native-loop-goal.js +33 -0
  766. package/neural-interface/lib/native-loop-providers.js +1016 -0
  767. package/neural-interface/lib/native-loop-runtime.js +747 -0
  768. package/neural-interface/lib/opencode-hidden-models.js +113 -0
  769. package/neural-interface/lib/opencode-history.js +253 -0
  770. package/neural-interface/lib/opencode-runtime-config.js +58 -0
  771. package/neural-interface/lib/opencode-v2-client.js +486 -0
  772. package/neural-interface/lib/opencode-v2-ws-requests.js +955 -0
  773. package/neural-interface/lib/path-confine.js +213 -0
  774. package/neural-interface/lib/plugin-installer.js +264 -0
  775. package/neural-interface/lib/project-storage.js +766 -0
  776. package/neural-interface/lib/pty-host/core.js +603 -0
  777. package/neural-interface/lib/pty-host/flow.js +43 -0
  778. package/neural-interface/lib/pty-host/host.js +47 -0
  779. package/neural-interface/lib/pty-host/manager.js +360 -0
  780. package/neural-interface/lib/pty-host/protocol.js +81 -0
  781. package/neural-interface/lib/pty-host/snapshot.js +133 -0
  782. package/neural-interface/lib/remote-policy.js +582 -0
  783. package/neural-interface/lib/rulesets/api.js +175 -0
  784. package/neural-interface/lib/rulesets/fs-safe.js +245 -0
  785. package/neural-interface/lib/rulesets/installer.js +1453 -0
  786. package/neural-interface/lib/rulesets/json-edit.js +186 -0
  787. package/neural-interface/lib/rulesets/lock.js +170 -0
  788. package/neural-interface/lib/rulesets/managed-text.js +314 -0
  789. package/neural-interface/lib/rulesets/normalise.js +24 -0
  790. package/neural-interface/lib/rulesets/render.js +84 -0
  791. package/neural-interface/lib/schedule-launch-intent.js +40 -0
  792. package/neural-interface/lib/session-cache-builder.js +524 -0
  793. package/neural-interface/lib/session-chunker.js +344 -0
  794. package/neural-interface/lib/session-indexer.js +327 -0
  795. package/neural-interface/lib/session-title-generator.js +227 -0
  796. package/neural-interface/lib/skill-prompt-bridge.js +11 -0
  797. package/neural-interface/lib/style-guide/api.js +375 -0
  798. package/neural-interface/lib/style-guide/color.js +352 -0
  799. package/neural-interface/lib/style-guide/export-css.js +99 -0
  800. package/neural-interface/lib/style-guide/export-dtcg.js +209 -0
  801. package/neural-interface/lib/style-guide/export-tailwind.js +102 -0
  802. package/neural-interface/lib/style-guide/fonts.json +227 -0
  803. package/neural-interface/lib/style-guide/import.js +1268 -0
  804. package/neural-interface/lib/style-guide/pointers.js +103 -0
  805. package/neural-interface/lib/style-guide/presets.js +232 -0
  806. package/neural-interface/lib/style-guide/render-design-md.js +319 -0
  807. package/neural-interface/lib/style-guide/schema.js +848 -0
  808. package/neural-interface/lib/style-guide/security.js +43 -0
  809. package/neural-interface/lib/style-guide/store.js +491 -0
  810. package/neural-interface/lib/style-guide/summary.js +106 -0
  811. package/neural-interface/lib/style-guide/tokens.js +170 -0
  812. package/neural-interface/lib/synabun-update-plan.js +305 -0
  813. package/neural-interface/lib/synabun-version.js +179 -0
  814. package/neural-interface/lib/system-restore.js +99 -0
  815. package/neural-interface/lib/terminal-env.js +86 -0
  816. package/neural-interface/lib/ui-blob-store.js +106 -0
  817. package/neural-interface/lib/vector-cache.js +2 -0
  818. package/neural-interface/lib/virtual-socket.js +60 -0
  819. package/neural-interface/lib/whatsapp/api.js +146 -0
  820. package/neural-interface/lib/whatsapp/auth-store.js +389 -0
  821. package/neural-interface/lib/whatsapp/baileys-adapter.js +457 -0
  822. package/neural-interface/lib/whatsapp/brain.js +128 -0
  823. package/neural-interface/lib/whatsapp/bridge.js +1499 -0
  824. package/neural-interface/lib/whatsapp/cards.js +519 -0
  825. package/neural-interface/lib/whatsapp/commands.js +92 -0
  826. package/neural-interface/lib/whatsapp/config.js +291 -0
  827. package/neural-interface/lib/whatsapp/connector/entry.mjs +25 -0
  828. package/neural-interface/lib/whatsapp/connector/manifest.json +6 -0
  829. package/neural-interface/lib/whatsapp/connector/npm-shrinkwrap.json +717 -0
  830. package/neural-interface/lib/whatsapp/connector/package.json +8 -0
  831. package/neural-interface/lib/whatsapp/fake-baileys.js +444 -0
  832. package/neural-interface/lib/whatsapp/format.js +552 -0
  833. package/neural-interface/lib/whatsapp/host-core.js +1225 -0
  834. package/neural-interface/lib/whatsapp/host.js +119 -0
  835. package/neural-interface/lib/whatsapp/identity.js +288 -0
  836. package/neural-interface/lib/whatsapp/inbound.js +149 -0
  837. package/neural-interface/lib/whatsapp/installer.js +562 -0
  838. package/neural-interface/lib/whatsapp/manager.js +638 -0
  839. package/neural-interface/lib/whatsapp/paths.js +81 -0
  840. package/neural-interface/lib/whatsapp/protocol.js +327 -0
  841. package/neural-interface/lib/whatsapp/qr-svg.js +57 -0
  842. package/neural-interface/lib/whatsapp/redact.js +130 -0
  843. package/neural-interface/lib/whatsapp/service.js +1305 -0
  844. package/neural-interface/lib/whatsapp/vendor/qrcodegen.js +848 -0
  845. package/neural-interface/package-lock.json +4682 -0
  846. package/neural-interface/package.json +58 -27
  847. package/neural-interface/public/apple-touch-icon.png +0 -0
  848. package/neural-interface/public/category-logos/synabun.png +0 -0
  849. package/neural-interface/public/claude.svg +1 -0
  850. package/neural-interface/public/favicon-16x16.png +0 -0
  851. package/neural-interface/public/favicon-32x32.png +0 -0
  852. package/neural-interface/public/icon-192.png +0 -0
  853. package/neural-interface/public/icon-512.png +0 -0
  854. package/neural-interface/public/index.html +32 -182
  855. package/neural-interface/public/index2d.html +2 -2
  856. package/neural-interface/public/logo-synabun.png +0 -0
  857. package/neural-interface/public/logoHD.png +0 -0
  858. package/neural-interface/public/mcp.svg +4 -0
  859. package/neural-interface/public/offline.html +335 -299
  860. package/neural-interface/public/onboarding.html +692 -155
  861. package/neural-interface/public/opencode-logo-dark-square.svg +1 -0
  862. package/neural-interface/public/shared/agent-runtime-options.js +596 -0
  863. package/neural-interface/public/shared/api.js +339 -28
  864. package/neural-interface/public/shared/assistant/asst-accounts.js +318 -0
  865. package/neural-interface/public/shared/assistant/asst-agents-dock.js +296 -0
  866. package/neural-interface/public/shared/assistant/asst-api.js +270 -0
  867. package/neural-interface/public/shared/assistant/asst-brain-picker.js +744 -0
  868. package/neural-interface/public/shared/assistant/asst-budget.js +333 -0
  869. package/neural-interface/public/shared/assistant/asst-composer.js +656 -0
  870. package/neural-interface/public/shared/assistant/asst-computer.js +803 -0
  871. package/neural-interface/public/shared/assistant/asst-control.js +1018 -0
  872. package/neural-interface/public/shared/assistant/asst-hosts.js +262 -0
  873. package/neural-interface/public/shared/assistant/asst-mascot.js +311 -0
  874. package/neural-interface/public/shared/assistant/asst-menu.js +375 -0
  875. package/neural-interface/public/shared/assistant/asst-panel.js +1845 -0
  876. package/neural-interface/public/shared/assistant/asst-rack-styles.js +589 -0
  877. package/neural-interface/public/shared/assistant/asst-render.js +3296 -0
  878. package/neural-interface/public/shared/assistant/asst-route.js +1653 -0
  879. package/neural-interface/public/shared/assistant/asst-roving.js +75 -0
  880. package/neural-interface/public/shared/assistant/asst-sanitize.js +117 -0
  881. package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +488 -0
  882. package/neural-interface/public/shared/assistant/asst-sidepanel.js +675 -0
  883. package/neural-interface/public/shared/assistant/asst-slash.js +258 -0
  884. package/neural-interface/public/shared/assistant/asst-state.js +727 -0
  885. package/neural-interface/public/shared/assistant/asst-styles.js +1684 -0
  886. package/neural-interface/public/shared/assistant/asst-tool-kinds.js +1017 -0
  887. package/neural-interface/public/shared/assistant/asst-usage.js +444 -0
  888. package/neural-interface/public/shared/assistant/asst-ws.js +175 -0
  889. package/neural-interface/public/shared/automation-context.js +75 -0
  890. package/neural-interface/public/shared/blob-store.js +99 -0
  891. package/neural-interface/public/shared/cdx/cdx-capabilities.js +118 -0
  892. package/neural-interface/public/shared/cdx/cdx-cards.js +37 -0
  893. package/neural-interface/public/shared/cdx/cdx-commands.js +171 -0
  894. package/neural-interface/public/shared/cdx/cdx-context-menu.js +402 -0
  895. package/neural-interface/public/shared/cdx/cdx-context-model.js +220 -0
  896. package/neural-interface/public/shared/cdx/cdx-features.js +408 -0
  897. package/neural-interface/public/shared/cdx/cdx-icons.js +89 -0
  898. package/neural-interface/public/shared/cdx/cdx-mcp-app-host.js +414 -0
  899. package/neural-interface/public/shared/cdx/cdx-mcp-app-proxy.html +6 -0
  900. package/neural-interface/public/shared/cdx/cdx-mcp-app-proxy.js +56 -0
  901. package/neural-interface/public/shared/cdx/cdx-mcp-app.js +178 -0
  902. package/neural-interface/public/shared/cdx/cdx-model-catalog.js +120 -0
  903. package/neural-interface/public/shared/cdx/cdx-output-model.js +261 -0
  904. package/neural-interface/public/shared/cdx/cdx-output.js +382 -0
  905. package/neural-interface/public/shared/cdx/cdx-panel.js +1372 -0
  906. package/neural-interface/public/shared/cdx/cdx-plan.js +266 -0
  907. package/neural-interface/public/shared/cdx/cdx-protocol.js +682 -0
  908. package/neural-interface/public/shared/cdx/cdx-render.js +3381 -0
  909. package/neural-interface/public/shared/cdx/cdx-requests.js +1136 -0
  910. package/neural-interface/public/shared/cdx/cdx-styles.js +3251 -0
  911. package/neural-interface/public/shared/cdx/cdx-tabs.js +7992 -0
  912. package/neural-interface/public/shared/cdx/cdx-ws.js +363 -0
  913. package/neural-interface/public/shared/cli-status.js +169 -0
  914. package/neural-interface/public/shared/constants.js +22 -5
  915. package/neural-interface/public/shared/cp/cp-agents.js +186 -0
  916. package/neural-interface/public/shared/cp/cp-bash.js +252 -0
  917. package/neural-interface/public/shared/cp/cp-compose.js +159 -0
  918. package/neural-interface/public/shared/cp/cp-context-menu.js +309 -0
  919. package/neural-interface/public/shared/cp/cp-context-model.js +157 -0
  920. package/neural-interface/public/shared/cp/cp-context-window.js +80 -0
  921. package/neural-interface/public/shared/cp/cp-ctx.js +11 -0
  922. package/neural-interface/public/shared/cp/cp-diff.js +255 -0
  923. package/neural-interface/public/shared/cp/cp-event-rows.js +637 -0
  924. package/neural-interface/public/shared/cp/cp-events.js +846 -0
  925. package/neural-interface/public/shared/cp/cp-markdown.js +104 -0
  926. package/neural-interface/public/shared/cp/cp-permission-model.js +905 -0
  927. package/neural-interface/public/shared/cp/cp-permissions.js +626 -0
  928. package/neural-interface/public/shared/cp/cp-rehydrate.js +113 -0
  929. package/neural-interface/public/shared/cp/cp-restore.js +224 -0
  930. package/neural-interface/public/shared/cp/cp-session-model.js +338 -0
  931. package/neural-interface/public/shared/cp/cp-session-settings.js +254 -0
  932. package/neural-interface/public/shared/cp/cp-sessions.js +227 -0
  933. package/neural-interface/public/shared/cp/cp-statusline.js +153 -0
  934. package/neural-interface/public/shared/cp/cp-styles.js +349 -0
  935. package/neural-interface/public/shared/cp/cp-tasks-model.js +282 -0
  936. package/neural-interface/public/shared/cp/cp-tasks.js +107 -0
  937. package/neural-interface/public/shared/cp/cp-temporary-view.js +56 -0
  938. package/neural-interface/public/shared/cp/cp-temporary.js +231 -0
  939. package/neural-interface/public/shared/cp/cp-tool-cards.js +326 -0
  940. package/neural-interface/public/shared/cp/cp-tool-results.js +618 -0
  941. package/neural-interface/public/shared/cp/cp-usage-model.js +124 -0
  942. package/neural-interface/public/shared/graph-activity.js +68 -0
  943. package/neural-interface/public/shared/html-shell.js +107 -12
  944. package/neural-interface/public/shared/i18n.js +132 -34
  945. package/neural-interface/public/shared/node-positions-store.js +44 -0
  946. package/neural-interface/public/shared/ocp-hidden-models.js +116 -0
  947. package/neural-interface/public/shared/ocp-v2/ocp-v2-approvals.js +355 -0
  948. package/neural-interface/public/shared/ocp-v2/ocp-v2-autoaccept-button.js +35 -0
  949. package/neural-interface/public/shared/ocp-v2/ocp-v2-automation-ownership.js +72 -0
  950. package/neural-interface/public/shared/ocp-v2/ocp-v2-binding.js +280 -0
  951. package/neural-interface/public/shared/ocp-v2/ocp-v2-caps.js +67 -0
  952. package/neural-interface/public/shared/ocp-v2/ocp-v2-changes-logic.js +179 -0
  953. package/neural-interface/public/shared/ocp-v2/ocp-v2-changes.js +164 -0
  954. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +725 -0
  955. package/neural-interface/public/shared/ocp-v2/ocp-v2-compact-button.js +75 -0
  956. package/neural-interface/public/shared/ocp-v2/ocp-v2-composer-logic.js +1204 -0
  957. package/neural-interface/public/shared/ocp-v2/ocp-v2-confirm-logic.js +117 -0
  958. package/neural-interface/public/shared/ocp-v2/ocp-v2-confirm.js +59 -0
  959. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-gauge.js +252 -0
  960. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-menu.js +478 -0
  961. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-model.js +179 -0
  962. package/neural-interface/public/shared/ocp-v2/ocp-v2-events.js +233 -0
  963. package/neural-interface/public/shared/ocp-v2/ocp-v2-icons.js +30 -0
  964. package/neural-interface/public/shared/ocp-v2/ocp-v2-manager.js +1065 -0
  965. package/neural-interface/public/shared/ocp-v2/ocp-v2-mention-hints.js +182 -0
  966. package/neural-interface/public/shared/ocp-v2/ocp-v2-modelpicker.js +655 -0
  967. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +2636 -0
  968. package/neural-interface/public/shared/ocp-v2/ocp-v2-plan.js +601 -0
  969. package/neural-interface/public/shared/ocp-v2/ocp-v2-profile-inheritance.js +78 -0
  970. package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +649 -0
  971. package/neural-interface/public/shared/ocp-v2/ocp-v2-providers.js +35 -0
  972. package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +357 -0
  973. package/neural-interface/public/shared/ocp-v2/ocp-v2-render-logic.js +189 -0
  974. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +1869 -0
  975. package/neural-interface/public/shared/ocp-v2/ocp-v2-select.js +42 -0
  976. package/neural-interface/public/shared/ocp-v2/ocp-v2-send-logic.js +117 -0
  977. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +1714 -0
  978. package/neural-interface/public/shared/ocp-v2/ocp-v2-session-actions.js +468 -0
  979. package/neural-interface/public/shared/ocp-v2/ocp-v2-session-widgets.js +63 -0
  980. package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +366 -0
  981. package/neural-interface/public/shared/ocp-v2/ocp-v2-settings-link.js +19 -0
  982. package/neural-interface/public/shared/ocp-v2/ocp-v2-slash-hints.js +343 -0
  983. package/neural-interface/public/shared/ocp-v2/ocp-v2-state.js +746 -0
  984. package/neural-interface/public/shared/ocp-v2/ocp-v2-status-logic.js +347 -0
  985. package/neural-interface/public/shared/ocp-v2/ocp-v2-status.js +338 -0
  986. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +3543 -0
  987. package/neural-interface/public/shared/ocp-v2/ocp-v2-subagents-logic.js +482 -0
  988. package/neural-interface/public/shared/ocp-v2/ocp-v2-tools-logic.js +351 -0
  989. package/neural-interface/public/shared/ocp-v2/ocp-v2-tools.js +168 -0
  990. package/neural-interface/public/shared/ocp-v2/ocp-v2-variantpicker.js +315 -0
  991. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +545 -0
  992. package/neural-interface/public/shared/provider-icons.js +30 -0
  993. package/neural-interface/public/shared/session-title.js +100 -0
  994. package/neural-interface/public/shared/settings/settings-ia.js +389 -0
  995. package/neural-interface/public/shared/settings/settings-kit.js +234 -0
  996. package/neural-interface/public/shared/settings/settings-search.js +463 -0
  997. package/neural-interface/public/shared/settings/settings-shell.js +570 -0
  998. package/neural-interface/public/shared/state.js +5 -0
  999. package/neural-interface/public/shared/storage.js +50 -7
  1000. package/neural-interface/public/shared/styleguide/sg-agents.js +42 -0
  1001. package/neural-interface/public/shared/styleguide/sg-brand.js +4 -0
  1002. package/neural-interface/public/shared/styleguide/sg-color-client.js +388 -0
  1003. package/neural-interface/public/shared/styleguide/sg-colors.js +148 -0
  1004. package/neural-interface/public/shared/styleguide/sg-components.js +35 -0
  1005. package/neural-interface/public/shared/styleguide/sg-history.js +41 -0
  1006. package/neural-interface/public/shared/styleguide/sg-icons-logo.js +38 -0
  1007. package/neural-interface/public/shared/styleguide/sg-imagery.js +4 -0
  1008. package/neural-interface/public/shared/styleguide/sg-import-export.js +47 -0
  1009. package/neural-interface/public/shared/styleguide/sg-kit.js +281 -0
  1010. package/neural-interface/public/shared/styleguide/sg-labels.js +318 -0
  1011. package/neural-interface/public/shared/styleguide/sg-layout.js +4 -0
  1012. package/neural-interface/public/shared/styleguide/sg-motion.js +5 -0
  1013. package/neural-interface/public/shared/styleguide/sg-panel.js +229 -0
  1014. package/neural-interface/public/shared/styleguide/sg-preview.js +138 -0
  1015. package/neural-interface/public/shared/styleguide/sg-rules.js +9 -0
  1016. package/neural-interface/public/shared/styleguide/sg-shape.js +24 -0
  1017. package/neural-interface/public/shared/styleguide/sg-state.js +132 -0
  1018. package/neural-interface/public/shared/styleguide/sg-typography.js +124 -0
  1019. package/neural-interface/public/shared/styles.css +7883 -1155
  1020. package/neural-interface/public/shared/synabun-mascot.js +1625 -0
  1021. package/neural-interface/public/shared/synabun-ticker.js +376 -0
  1022. package/neural-interface/public/shared/term-renderer.js +343 -0
  1023. package/neural-interface/public/shared/ui-assistant-panel.js +10 -0
  1024. package/neural-interface/public/shared/ui-assistant.js +77 -0
  1025. package/neural-interface/public/shared/ui-automation-studio.js +5105 -3573
  1026. package/neural-interface/public/shared/ui-claude-panel.js +11685 -6333
  1027. package/neural-interface/public/shared/ui-codex-panel.js +12 -0
  1028. package/neural-interface/public/shared/ui-cost-widget.js +55 -34
  1029. package/neural-interface/public/shared/ui-detail.js +77 -14
  1030. package/neural-interface/public/shared/ui-explorer.js +360 -181
  1031. package/neural-interface/public/shared/ui-file-explorer.js +485 -107
  1032. package/neural-interface/public/shared/ui-help.js +2 -2
  1033. package/neural-interface/public/shared/ui-image-gallery.js +45 -21
  1034. package/neural-interface/public/shared/ui-judgments.js +1346 -0
  1035. package/neural-interface/public/shared/ui-keybinds.js +3 -2
  1036. package/neural-interface/public/shared/ui-link.js +105 -51
  1037. package/neural-interface/public/shared/ui-loading.js +4 -4
  1038. package/neural-interface/public/shared/ui-memory-maintenance.js +111 -0
  1039. package/neural-interface/public/shared/ui-menubar.js +89 -4
  1040. package/neural-interface/public/shared/ui-native-loop-router-state.js +94 -0
  1041. package/neural-interface/public/shared/ui-native-loop-router.js +493 -0
  1042. package/neural-interface/public/shared/ui-native-window-id.js +7 -0
  1043. package/neural-interface/public/shared/ui-navbar.js +357 -222
  1044. package/neural-interface/public/shared/ui-notifications.js +58 -12
  1045. package/neural-interface/public/shared/ui-opencode-panel-v2.js +17 -0
  1046. package/neural-interface/public/shared/ui-panels.js +15 -7
  1047. package/neural-interface/public/shared/ui-resume.js +823 -653
  1048. package/neural-interface/public/shared/ui-schedule-queue.js +172 -0
  1049. package/neural-interface/public/shared/ui-schedules-studio.js +1882 -0
  1050. package/neural-interface/public/shared/ui-scroll-follow.js +194 -0
  1051. package/neural-interface/public/shared/ui-sessions.js +789 -34
  1052. package/neural-interface/public/shared/ui-settings.js +5793 -2190
  1053. package/neural-interface/public/shared/ui-sidepanel-layout.js +109 -0
  1054. package/neural-interface/public/shared/ui-sidepanel-runtime.js +19 -0
  1055. package/neural-interface/public/shared/ui-sidepanel-session-entry.js +40 -0
  1056. package/neural-interface/public/shared/ui-sidepanel-sessions.js +221 -0
  1057. package/neural-interface/public/shared/ui-sidepanel-state.js +131 -0
  1058. package/neural-interface/public/shared/ui-sidepanel-tray.js +547 -0
  1059. package/neural-interface/public/shared/ui-sidepanel-windows.js +387 -0
  1060. package/neural-interface/public/shared/ui-skills.js +18 -32
  1061. package/neural-interface/public/shared/ui-styleguide.js +35 -0
  1062. package/neural-interface/public/shared/ui-sync.js +87 -0
  1063. package/neural-interface/public/shared/ui-terminal.js +7877 -6385
  1064. package/neural-interface/public/shared/ui-tool-updates.js +292 -0
  1065. package/neural-interface/public/shared/ui-tooltip.js +156 -15
  1066. package/neural-interface/public/shared/ui-tutorial-draw.js +119 -683
  1067. package/neural-interface/public/shared/ui-tutorial-layout.js +135 -0
  1068. package/neural-interface/public/shared/ui-tutorial-steps.js +59 -2209
  1069. package/neural-interface/public/shared/ui-tutorial.css +102 -0
  1070. package/neural-interface/public/shared/ui-tutorial.js +557 -1076
  1071. package/neural-interface/public/shared/ui-update.js +420 -68
  1072. package/neural-interface/public/shared/ui-whatsapp.js +941 -0
  1073. package/neural-interface/public/shared/ui-whiteboard.js +1076 -233
  1074. package/neural-interface/public/shared/ui-workspaces.js +2 -1
  1075. package/neural-interface/public/shared/utils.js +58 -0
  1076. package/neural-interface/public/shared/vterm-buffer.js +15 -5
  1077. package/neural-interface/public/shared/whatsapp/wa-phone.js +65 -0
  1078. package/neural-interface/public/shared/whatsapp/wa-view.js +411 -0
  1079. package/neural-interface/public/sidepanel-session.html +18 -0
  1080. package/neural-interface/public/sw.js +8 -2
  1081. package/neural-interface/public/synabun.png +0 -0
  1082. package/neural-interface/public/variant/2d/graph.js +1707 -1635
  1083. package/neural-interface/public/variant/2d/lasso.js +2 -1
  1084. package/neural-interface/public/variant/2d/layout.js +595 -594
  1085. package/neural-interface/public/variant/2d/main.js +556 -521
  1086. package/neural-interface/public/variant/2d/settings-gfx.js +18 -24
  1087. package/neural-interface/public/variant/3d/main.js +516 -500
  1088. package/neural-interface/public/variant/3d/map-controls.js +144 -0
  1089. package/neural-interface/public/variant/3d/map-data.js +251 -0
  1090. package/neural-interface/public/variant/3d/map-labels.js +241 -0
  1091. package/neural-interface/public/variant/3d/map-pick.js +90 -0
  1092. package/neural-interface/public/variant/3d/map-renderer.js +521 -0
  1093. package/neural-interface/public/variant/3d/map-shaders.js +158 -0
  1094. package/neural-interface/public/variant/3d/map-view.js +618 -0
  1095. package/neural-interface/public/vendor/three/manifest.json +3 -0
  1096. package/neural-interface/public/vendor/three/three-0.169.0.min.js +3844 -0
  1097. package/neural-interface/public/vendor/xterm/manifest.json +3 -0
  1098. package/neural-interface/public/vendor/xterm/xterm-6.0.0.css +285 -0
  1099. package/neural-interface/public/vendor/xterm/xterm-6.0.0.min.js +192 -0
  1100. package/neural-interface/scripts/build-vendor.mjs +64 -0
  1101. package/neural-interface/scripts/codex-protocol-fixture.mjs +83 -0
  1102. package/neural-interface/scripts/rebuild-pty.js +37 -23
  1103. package/neural-interface/scripts/vendor-three-entry.js +24 -0
  1104. package/neural-interface/scripts/vendor-xterm-entry.js +8 -0
  1105. package/neural-interface/server.js +33948 -15000
  1106. package/neural-interface/skins/default/skin.css +90 -0
  1107. package/neural-interface/skins/default/skin.json +8 -0
  1108. package/neural-interface/templates/rulesets/README.md +9 -0
  1109. package/neural-interface/templates/rulesets/claude.md +3 -0
  1110. package/neural-interface/templates/rulesets/codex.md +2 -0
  1111. package/neural-interface/templates/rulesets/coexistence.md +6 -0
  1112. package/neural-interface/templates/rulesets/core.md +26 -0
  1113. package/neural-interface/templates/rulesets/cursor.md +2 -0
  1114. package/neural-interface/templates/rulesets/gemini.md +2 -0
  1115. package/neural-interface/templates/rulesets/legacy-hashes.json +227 -0
  1116. package/neural-interface/templates/rulesets/manifest.json +12 -0
  1117. package/neural-interface/templates/rulesets/opencode.md +2 -0
  1118. package/package.json +119 -53
  1119. package/postinstall.js +125 -26
  1120. package/preuninstall.js +187 -0
  1121. package/scripts/audit-claude-hook-usage.mjs +12 -0
  1122. package/scripts/audit-memory-duplicates.mjs +44 -0
  1123. package/scripts/measure-claude-hook-overhead.mjs +77 -0
  1124. package/scripts/recover-user-config.mjs +254 -0
  1125. package/scripts/restore-data-home-from-backup.mjs +239 -0
  1126. package/setup.js +804 -260
  1127. package/skills/leonardo/SKILL.md +19 -10
  1128. package/skills/leonardo/modules/image-prompter.md +18 -12
  1129. package/skills/leonardo/modules/video-prompter.md +19 -13
  1130. package/skills/synabun/SKILL.md +209 -185
  1131. package/skills/synabun/icon.png +0 -0
  1132. package/skills/synabun/modules/audit.md +380 -374
  1133. package/skills/synabun/modules/changelog.md +15 -6
  1134. package/skills/synabun/modules/idea.md +148 -144
  1135. package/skills/synabun/modules/memorize.md +13 -4
  1136. package/skills/synabun/modules/schedule.md +198 -0
  1137. package/skills/synabun/synabunicon.png +0 -0
  1138. package/updater.cmd +31 -0
  1139. package/updater.mjs +382 -0
  1140. package/updater.sh +29 -0
  1141. package/hooks/claude-code/test-user-learning.mjs +0 -531
  1142. package/neural-interface/public/claude-chat.css +0 -878
  1143. package/neural-interface/public/claude-chat.html +0 -92
  1144. package/neural-interface/public/claude-chat.js +0 -1168
  1145. package/neural-interface/public/index-legacy.html +0 -14677
  1146. package/neural-interface/public/index2d-legacy.html +0 -8193
  1147. package/neural-interface/public/shared/html-term-renderer.js +0 -1022
  1148. package/neural-interface/public/variant/3d/background.js +0 -884
  1149. package/neural-interface/public/variant/3d/camera.js +0 -661
  1150. package/neural-interface/public/variant/3d/gfx.js +0 -116
  1151. package/neural-interface/public/variant/3d/graph.js +0 -2399
  1152. package/neural-interface/public/variant/3d/settings-gfx.js +0 -228
  1153. package/neural-interface/templates/CLAUDE-template.md +0 -211
@@ -0,0 +1,3056 @@
1
+ // SynaBun desktop helper — the native macOS backend of the Assistant's computer use.
2
+ //
3
+ // One long-lived process, spawned by lib/desktop/manager.js and compiled on the
4
+ // user's machine by lib/desktop/build.js:
5
+ // xcrun swiftc -O -swift-version 5 -target <arch>-apple-macos14.0 -o <bin> SynabunDesktop.swift
6
+ //
7
+ // Protocol: JSON lines over stdio (UTF-8, one object per line). stdout carries
8
+ // nothing else — diagnostics go to stderr or to `log` events.
9
+ // request {"id":17,"cmd":"click","args":{...}}
10
+ // reply {"id":17,"ok":true,"result":{...}}
11
+ // {"id":17,"ok":false,"error":{"code":"SECURE_FIELD","message":"...","details":{...}}}
12
+ // event {"event":"ready"|"user_input"|"emergency_stop"|"screen_lock"|
13
+ // "displays_changed"|"permissions_changed"|"log", ...} (no id)
14
+ //
15
+ // Actions run strictly serially in arrival order on one queue. abort, panic,
16
+ // permissions, session_state, cursor and shutdown bypass that queue and are
17
+ // answered from the stdin thread immediately.
18
+ //
19
+ // Coordinates: every point received or returned is a Quartz GLOBAL DISPLAY POINT
20
+ // (origin at the top-left of the main display, y grows down; secondary displays
21
+ // may have negative origins). Screenshot pixel space never enters this process
22
+ // except for the fitted image it produces.
23
+ //
24
+ // Safety model (the helper is the last line of defence, so it re-checks
25
+ // everything right before posting events):
26
+ // - no Accessibility → NOT_TRUSTED for input/AX; no Screen Recording →
27
+ // NO_SCREEN_RECORDING for capture. Nothing here ever prompts except
28
+ // `request_permission`, and nothing is posted without the post-event grant.
29
+ // - refuseWhenLocked: a locked (or off-console) session refuses every action.
30
+ // - blocked apps / protected windows are evaluated on the probed target
31
+ // (pointer actions) or the focused element + frontmost window (keyboard).
32
+ // Rules match bundle ids, bundle-id prefixes (web apps), app names and
33
+ // window titles; names and titles are NFC-normalized first, so a title in
34
+ // decomposed form still matches a localized pattern.
35
+ // - ax_action press may carry `verify` (protocol 2): the element is re-read
36
+ // right before AXPerformAction and nothing is pressed (TARGET_CHANGED) unless
37
+ // it is still the same, enabled, pressable control in the app's frontmost,
38
+ // focused, sheet-free window.
39
+ // - typing / printable keys / paste / AX set_value never target an
40
+ // AXSecureTextField, and fail closed when focus is unreadable while secure
41
+ // event input is on. The value of a secure field is never read.
42
+ // - every synthetic event carries kCGEventSourceUserData = "SYNB" so the
43
+ // global monitor can tell the user's input from ours. While the monitor is
44
+ // armed, an unstamped Escape or a flick into the top-left corner of the main
45
+ // display emits `emergency_stop`, aborts the in-flight action, releases held
46
+ // input and LATCHES: input/AX/app actions then fail with INTERRUPTED
47
+ // (details.latched) until the next `configure`.
48
+ //
49
+ // Lifetime: exits (after releasing anything it holds) on `shutdown`, stdin EOF,
50
+ // SIGTERM/SIGHUP, or when its parent dies (getppid() == 1, polled every 2 s).
51
+ //
52
+ // Flags: --version prints {"version","protocol"}; --self-test runs the pure
53
+ // checks (key combos, fit math, scroll signs, text chunking, guard matching,
54
+ // JSON round-trip) with no screen access and exits 0/1.
55
+
56
+ import AppKit
57
+ import ApplicationServices
58
+ import Carbon
59
+ import CoreGraphics
60
+ import Darwin
61
+ import Foundation
62
+ import ImageIO
63
+ import IOKit.pwr_mgt
64
+ import ScreenCaptureKit
65
+
66
+ // MARK: - Constants
67
+
68
+ let HELPER_VERSION = "1.1.0"
69
+ let PROTOCOL_VERSION = 2
70
+ /// "SYNB" — stamped into kCGEventSourceUserData of every event this process posts.
71
+ let SYNB_STAMP: Int64 = 0x53594E42
72
+
73
+ #if arch(arm64)
74
+ let HELPER_ARCH = "arm64"
75
+ #elseif arch(x86_64)
76
+ let HELPER_ARCH = "x64"
77
+ #else
78
+ let HELPER_ARCH = "unknown"
79
+ #endif
80
+
81
+ // MARK: - Errors
82
+
83
+ struct HelperError: Error {
84
+ let code: String
85
+ let message: String
86
+ let details: [String: Any]?
87
+ init(_ code: String, _ message: String, _ details: [String: Any]? = nil) {
88
+ self.code = code
89
+ self.message = message
90
+ self.details = details
91
+ }
92
+ func adding(_ extra: [String: Any]) -> HelperError {
93
+ var d = details ?? [:]
94
+ for (k, v) in extra { d[k] = v }
95
+ return HelperError(code, message, d)
96
+ }
97
+ }
98
+
99
+ // MARK: - JSON output
100
+
101
+ /// Anything → a JSONSerialization-safe value (optionals unwrapped, NaN/Inf → null,
102
+ /// CG geometry flattened). JSONSerialization raises an uncatchable ObjC exception
103
+ /// on invalid input, so every outgoing object goes through here.
104
+ func jsonSafe(_ value: Any?) -> Any {
105
+ guard let value = value else { return NSNull() }
106
+ let mirror = Mirror(reflecting: value)
107
+ if mirror.displayStyle == .optional {
108
+ guard let inner = mirror.children.first?.value else { return NSNull() }
109
+ return jsonSafe(inner)
110
+ }
111
+ switch value {
112
+ case is NSNull: return value
113
+ case let s as String: return s
114
+ case let n as NSNumber: return n.doubleValue.isFinite ? n : NSNull()
115
+ case let d as [String: Any]: return d.mapValues { jsonSafe($0) }
116
+ case let a as [Any]: return a.map { jsonSafe($0) }
117
+ case let p as CGPoint: return pointJSON(p)
118
+ case let r as CGRect: return rectJSON(r)
119
+ default: return String(describing: value)
120
+ }
121
+ }
122
+
123
+ func encodeJSON(_ obj: [String: Any]) -> Data? {
124
+ let safe = jsonSafe(obj)
125
+ guard JSONSerialization.isValidJSONObject(safe) else { return nil }
126
+ return try? JSONSerialization.data(withJSONObject: safe, options: [.withoutEscapingSlashes])
127
+ }
128
+
129
+ func stderrLine(_ s: String) {
130
+ FileHandle.standardError.write((s + "\n").data(using: .utf8) ?? Data())
131
+ }
132
+
133
+ enum Out {
134
+ private static let lock = NSLock()
135
+ private static var broken = false
136
+
137
+ /// One JSON object per line, written with write(2) under a lock so replies
138
+ /// from the stdin thread, the action queue and the main thread never interleave.
139
+ static func send(_ obj: [String: Any]) {
140
+ guard var data = encodeJSON(obj) else {
141
+ stderrLine("synabun-desktop: dropped an unserializable message")
142
+ return
143
+ }
144
+ data.append(0x0A)
145
+ lock.lock()
146
+ defer { lock.unlock() }
147
+ if broken { return }
148
+ let ok = data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
149
+ guard var p = raw.baseAddress else { return true }
150
+ var left = raw.count
151
+ while left > 0 {
152
+ let n = Darwin.write(STDOUT_FILENO, p, left)
153
+ if n < 0 {
154
+ if errno == EINTR { continue }
155
+ return false
156
+ }
157
+ p = p.advanced(by: n)
158
+ left -= n
159
+ }
160
+ return true
161
+ }
162
+ if !ok {
163
+ broken = true
164
+ DispatchQueue.global().async { Lifecycle.exit(reason: "stdout closed") }
165
+ }
166
+ }
167
+ }
168
+
169
+ func emitEvent(_ name: String, _ fields: [String: Any] = [:]) {
170
+ var obj = fields
171
+ obj["event"] = name
172
+ Out.send(obj)
173
+ }
174
+
175
+ func hlog(_ level: String, _ message: String) {
176
+ emitEvent("log", ["level": level, "message": message])
177
+ }
178
+
179
+ func reply(_ id: Any?, result: Any) {
180
+ Out.send(["id": id ?? NSNull(), "ok": true, "result": result])
181
+ }
182
+
183
+ func replyError(_ id: Any?, _ e: HelperError) {
184
+ var err: [String: Any] = ["code": e.code, "message": e.message]
185
+ if let d = e.details { err["details"] = d }
186
+ Out.send(["id": id ?? NSNull(), "ok": false, "error": err])
187
+ }
188
+
189
+ // MARK: - Small utilities
190
+
191
+ func rectJSON(_ r: CGRect) -> [String: Any] {
192
+ ["x": Double(r.origin.x), "y": Double(r.origin.y), "w": Double(r.size.width), "h": Double(r.size.height)]
193
+ }
194
+
195
+ func pointJSON(_ p: CGPoint) -> [String: Any] { ["x": Double(p.x), "y": Double(p.y)] }
196
+
197
+ func nowNs() -> UInt64 { DispatchTime.now().uptimeNanoseconds }
198
+
199
+ func epochMs() -> Double { (Date().timeIntervalSince1970 * 1000).rounded() }
200
+
201
+ func sleepMs(_ ms: Double) {
202
+ guard ms > 0 else { return }
203
+ usleep(useconds_t(min(ms, 60_000) * 1000))
204
+ }
205
+
206
+ func clampInt(_ v: Int, _ lo: Int, _ hi: Int) -> Int { max(lo, min(hi, v)) }
207
+
208
+ func clampDouble(_ v: Double, _ lo: Double, _ hi: Double) -> Double { max(lo, min(hi, v)) }
209
+
210
+ /// JSON number → Int without trapping on absurd values (Int(Double) traps out of range).
211
+ func toInt(_ d: Double) -> Int { d.isFinite ? Int(clampDouble(d.rounded(), -1e15, 1e15)) : 0 }
212
+
213
+ /// AppKit and the Text Input Sources API want the main thread (TSM asserts on it
214
+ /// since macOS 14). The main thread only runs the run loop, so a sync hop is safe
215
+ /// from the stdin thread and the action queue.
216
+ func onMain<T>(_ body: () throws -> T) rethrows -> T {
217
+ if Thread.isMainThread { return try body() }
218
+ return try DispatchQueue.main.sync(execute: body)
219
+ }
220
+
221
+ final class Box<T> {
222
+ var value: T?
223
+ var error: Error?
224
+ }
225
+
226
+ /// Bridges a completion-handler API to the calling (non-main) thread.
227
+ func waitFor<T>(_ what: String, timeout: Double, _ start: (@escaping (T?, Error?) -> Void) -> Void) throws -> T {
228
+ let box = Box<T>()
229
+ let sem = DispatchSemaphore(value: 0)
230
+ start { value, error in
231
+ box.value = value
232
+ box.error = error
233
+ sem.signal()
234
+ }
235
+ if sem.wait(timeout: .now() + timeout) == .timedOut {
236
+ throw HelperError("TIMEOUT", "\(what) did not answer within \(Int(timeout)) s")
237
+ }
238
+ if let e = box.error { throw e }
239
+ guard let v = box.value else { throw HelperError("INTERNAL", "\(what) returned nothing") }
240
+ return v
241
+ }
242
+
243
+ func trimmed(_ s: String?, _ limit: Int) -> String? {
244
+ guard let s = s else { return nil }
245
+ return s.count > limit ? String(s.prefix(limit)) : s
246
+ }
247
+
248
+ // MARK: - Arguments
249
+
250
+ struct Args {
251
+ let raw: [String: Any]
252
+ init(_ raw: [String: Any]) { self.raw = raw }
253
+
254
+ static func num(_ v: Any?) -> Double? {
255
+ guard let n = v as? NSNumber, CFGetTypeID(n as CFTypeRef) != CFBooleanGetTypeID() else { return nil }
256
+ let d = n.doubleValue
257
+ return d.isFinite ? d : nil
258
+ }
259
+ static func bool(_ v: Any?) -> Bool? { (v as? NSNumber)?.boolValue }
260
+
261
+ func has(_ k: String) -> Bool { raw[k] != nil && !(raw[k] is NSNull) }
262
+ func num(_ k: String) -> Double? { Args.num(raw[k]) }
263
+ func int(_ k: String) -> Int? { num(k).map(toInt) }
264
+ func string(_ k: String) -> String? { raw[k] as? String }
265
+ func bool(_ k: String) -> Bool? { Args.bool(raw[k]) }
266
+ func dict(_ k: String) -> [String: Any]? { raw[k] as? [String: Any] }
267
+ func array(_ k: String) -> [Any]? { raw[k] as? [Any] }
268
+
269
+ static func point(_ d: [String: Any]?, _ name: String) throws -> CGPoint {
270
+ guard let d = d, let x = num(d["x"]), let y = num(d["y"]) else {
271
+ throw HelperError("BAD_ARGS", "\(name) needs numeric x and y (global display points)")
272
+ }
273
+ return CGPoint(x: x, y: y)
274
+ }
275
+ func point() throws -> CGPoint { try Args.point(raw, "this command") }
276
+
277
+ static func rect(_ d: [String: Any]?, _ name: String) throws -> CGRect {
278
+ guard let d = d, let x = num(d["x"]), let y = num(d["y"]), let w = num(d["w"]), let h = num(d["h"]), w > 0, h > 0 else {
279
+ throw HelperError("BAD_ARGS", "\(name) needs numeric x, y and positive w, h")
280
+ }
281
+ return CGRect(x: x, y: y, width: w, height: h)
282
+ }
283
+ }
284
+
285
+ // MARK: - Pure logic (covered by --self-test)
286
+
287
+ struct FitResult {
288
+ let w: Int
289
+ let h: Int
290
+ let factor: Double
291
+ }
292
+
293
+ /// Output size for a W×H point area on a display with backing scale s, fitted
294
+ /// into fit.w × fit.h: f = min(fit.w/W, fit.h/H, s); image = round(W·f) × round(H·f).
295
+ /// Never upscales past native pixels.
296
+ func fitSize(width W: Double, height H: Double, fitW: Double, fitH: Double, scale s: Double) -> FitResult {
297
+ guard W > 0, H > 0 else { return FitResult(w: 0, h: 0, factor: 0) }
298
+ let fw = fitW > 0 ? fitW : W * s
299
+ let fh = fitH > 0 ? fitH : H * s
300
+ let f = max(1e-6, min(fw / W, fh / H, s > 0 ? s : 1))
301
+ return FitResult(w: max(1, Int((W * f).rounded())), h: max(1, Int((H * f).rounded())), factor: f)
302
+ }
303
+
304
+ /// Protocol: dy > 0 scrolls DOWN (toward the end of the document), dx > 0 scrolls
305
+ /// RIGHT. Quartz scroll-wheel events are the other way round: wheel1 (vertical)
306
+ /// is positive for UP and wheel2 (horizontal) is positive for LEFT. So both signs
307
+ /// flip. Synthetic deltas are not affected by the "natural scrolling" setting.
308
+ func scrollWheelDeltas(dx: Int, dy: Int) -> (wheel1: Int32, wheel2: Int32) {
309
+ (Int32(clamping: -clampInt(dy, -1_000_000, 1_000_000)), Int32(clamping: -clampInt(dx, -1_000_000, 1_000_000)))
310
+ }
311
+
312
+ enum Mod: String, CaseIterable {
313
+ case ctrl, alt, shift, cmd, fn
314
+
315
+ var flag: CGEventFlags {
316
+ switch self {
317
+ case .cmd: return .maskCommand
318
+ case .shift: return .maskShift
319
+ case .alt: return .maskAlternate
320
+ case .ctrl: return .maskControl
321
+ case .fn: return .maskSecondaryFn
322
+ }
323
+ }
324
+ /// kVK_Command, kVK_Shift, kVK_Option, kVK_Control, kVK_Function
325
+ var keyCode: CGKeyCode {
326
+ switch self {
327
+ case .cmd: return 0x37
328
+ case .shift: return 0x38
329
+ case .alt: return 0x3A
330
+ case .ctrl: return 0x3B
331
+ case .fn: return 0x3F
332
+ }
333
+ }
334
+ /// Right-hand twins released by `panic` (kVK_RightCommand/Shift/Option/Control).
335
+ var rightKeyCode: CGKeyCode? {
336
+ switch self {
337
+ case .cmd: return 0x36
338
+ case .shift: return 0x3C
339
+ case .alt: return 0x3D
340
+ case .ctrl: return 0x3E
341
+ case .fn: return nil
342
+ }
343
+ }
344
+ static func parse(_ s: String) -> Mod? {
345
+ switch s.lowercased() {
346
+ case "cmd", "command", "super": return .cmd
347
+ case "shift": return .shift
348
+ case "alt", "option", "opt": return .alt
349
+ case "ctrl", "control": return .ctrl
350
+ case "fn", "function": return .fn
351
+ default: return nil
352
+ }
353
+ }
354
+ }
355
+
356
+ /// xdotool-style key names → kVK_* virtual key codes (layout independent keys).
357
+ let NAMED_KEYS: [String: CGKeyCode] = [
358
+ "return": 0x24, "enter": 0x24, "kp_enter": 0x4C, "tab": 0x30, "escape": 0x35, "esc": 0x35,
359
+ "backspace": 0x33, "delete": 0x75, "forwarddelete": 0x75,
360
+ "up": 0x7E, "down": 0x7D, "left": 0x7B, "right": 0x7C,
361
+ "home": 0x73, "end": 0x77, "page_up": 0x74, "pageup": 0x74, "prior": 0x74,
362
+ "page_down": 0x79, "pagedown": 0x79, "next": 0x79, "help": 0x72, "insert": 0x72,
363
+ "f1": 0x7A, "f2": 0x78, "f3": 0x63, "f4": 0x76, "f5": 0x60, "f6": 0x61, "f7": 0x62,
364
+ "f8": 0x64, "f9": 0x65, "f10": 0x6D, "f11": 0x67, "f12": 0x6F, "f13": 0x69, "f14": 0x6B,
365
+ "f15": 0x71, "f16": 0x6A, "f17": 0x40, "f18": 0x4F, "f19": 0x50, "f20": 0x5A,
366
+ ]
367
+
368
+ /// xdotool keysym names that stand for a character.
369
+ let KEYSYM_CHARS: [String: Character] = [
370
+ "space": " ", "minus": "-", "equal": "=", "plus": "+", "underscore": "_", "comma": ",",
371
+ "period": ".", "slash": "/", "backslash": "\\", "semicolon": ";", "colon": ":",
372
+ "apostrophe": "'", "quoteright": "'", "quotedbl": "\"", "grave": "`", "quoteleft": "`",
373
+ "asciitilde": "~", "bracketleft": "[", "bracketright": "]", "braceleft": "{", "braceright": "}",
374
+ "parenleft": "(", "parenright": ")", "less": "<", "greater": ">", "question": "?",
375
+ "exclam": "!", "at": "@", "numbersign": "#", "dollar": "$", "percent": "%",
376
+ "asciicircum": "^", "ampersand": "&", "asterisk": "*", "bar": "|",
377
+ ]
378
+
379
+ /// US ANSI positions — the fallback when the current layout can't produce a character
380
+ /// (shortcuts on non-Latin layouts resolve through the ANSI position, like macOS does).
381
+ let ANSI_KEYS: [Character: CGKeyCode] = [
382
+ "a": 0x00, "s": 0x01, "d": 0x02, "f": 0x03, "h": 0x04, "g": 0x05, "z": 0x06, "x": 0x07,
383
+ "c": 0x08, "v": 0x09, "b": 0x0B, "q": 0x0C, "w": 0x0D, "e": 0x0E, "r": 0x0F, "y": 0x10,
384
+ "t": 0x11, "1": 0x12, "2": 0x13, "3": 0x14, "4": 0x15, "6": 0x16, "5": 0x17, "=": 0x18,
385
+ "9": 0x19, "7": 0x1A, "-": 0x1B, "8": 0x1C, "0": 0x1D, "]": 0x1E, "o": 0x1F, "u": 0x20,
386
+ "[": 0x21, "i": 0x22, "p": 0x23, "l": 0x25, "j": 0x26, "'": 0x27, "k": 0x28, ";": 0x29,
387
+ "\\": 0x2A, ",": 0x2B, "/": 0x2C, "n": 0x2D, "m": 0x2E, ".": 0x2F, "`": 0x32, " ": 0x31,
388
+ ]
389
+ let ANSI_SHIFTED: [Character: Character] = [
390
+ "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", "*": "8", "(": "9",
391
+ ")": "0", "_": "-", "+": "=", "{": "[", "}": "]", "|": "\\", ":": ";", "\"": "'", "<": ",",
392
+ ">": ".", "?": "/", "~": "`",
393
+ ]
394
+
395
+ /// Character → (virtual key, UCKeyTranslate modifier state: 0 none, 2 shift, 8 option, 10 both).
396
+ typealias LayoutMap = [String: (code: CGKeyCode, state: UInt32)]
397
+
398
+ struct ResolvedChar {
399
+ let code: CGKeyCode
400
+ let shift: Bool
401
+ let alt: Bool
402
+ }
403
+
404
+ func resolveChar(_ ch: Character, layout: LayoutMap?, ansiFallback: Bool) -> ResolvedChar? {
405
+ let s = String(ch)
406
+ if let layout = layout {
407
+ if let m = layout[s] { return ResolvedChar(code: m.code, shift: m.state & 2 != 0, alt: m.state & 8 != 0) }
408
+ let lower = s.lowercased()
409
+ if lower != s, let m = layout[lower], m.state == 0 { return ResolvedChar(code: m.code, shift: true, alt: false) }
410
+ }
411
+ guard ansiFallback || layout == nil || layout!.isEmpty else { return nil }
412
+ let lower = s.lowercased()
413
+ if lower.count == 1, let c = ANSI_KEYS[Character(lower)] { return ResolvedChar(code: c, shift: lower != s, alt: false) }
414
+ if let base = ANSI_SHIFTED[ch], let c = ANSI_KEYS[base] { return ResolvedChar(code: c, shift: true, alt: false) }
415
+ return nil
416
+ }
417
+
418
+ struct KeyCombo {
419
+ var mods: [Mod] = []
420
+ var keyCode: CGKeyCode? = nil
421
+ var keyName = ""
422
+ /// The key produces a character (letters, digits, punctuation, space).
423
+ var isCharKey = false
424
+
425
+ var flags: CGEventFlags { mods.reduce(CGEventFlags()) { $0.union($1.flag) } }
426
+ /// Printable input (no modifiers other than shift/option) or a paste (cmd+v):
427
+ /// these must never land in a secure field.
428
+ var needsSecureCheck: Bool {
429
+ guard keyCode != nil else { return false }
430
+ if isCharKey && mods.allSatisfy({ $0 == .shift || $0 == .alt }) { return true }
431
+ if keyName == "v" && mods.contains(.cmd) { return true }
432
+ return false
433
+ }
434
+ }
435
+
436
+ /// "cmd+shift+t", "Return", "ctrl+alt+Delete", "cmd++", "shift" (modifier only).
437
+ /// An uppercase single letter implies shift ("cmd+T" == "cmd+shift+t").
438
+ func parseCombo(_ raw: String, layout: LayoutMap?) throws -> KeyCombo {
439
+ let s = raw.trimmingCharacters(in: .whitespaces)
440
+ guard !s.isEmpty else { throw HelperError("BAD_ARGS", "empty key combo") }
441
+ var tokens: [String]
442
+ if s == "+" {
443
+ tokens = ["+"]
444
+ } else if s.hasSuffix("++") {
445
+ tokens = s.dropLast(2).split(separator: "+", omittingEmptySubsequences: false).map(String.init) + ["+"]
446
+ } else {
447
+ tokens = s.split(separator: "+", omittingEmptySubsequences: false).map(String.init)
448
+ }
449
+ tokens = tokens.map { $0.trimmingCharacters(in: .whitespaces) }
450
+ if tokens.contains(where: { $0.isEmpty }) { throw HelperError("BAD_ARGS", "malformed key combo '\(raw)'") }
451
+ var combo = KeyCombo()
452
+ for (i, token) in tokens.enumerated() {
453
+ let last = i == tokens.count - 1
454
+ if let m = Mod.parse(token) {
455
+ if !combo.mods.contains(m) { combo.mods.append(m) }
456
+ continue
457
+ }
458
+ guard last else { throw HelperError("BAD_ARGS", "unknown modifier '\(token)' in '\(raw)'") }
459
+ let lower = token.lowercased()
460
+ if let code = NAMED_KEYS[lower] {
461
+ combo.keyCode = code
462
+ combo.keyName = lower
463
+ continue
464
+ }
465
+ var ch: Character? = nil
466
+ if let c = KEYSYM_CHARS[lower] { ch = c } else if token.count == 1 { ch = token.first }
467
+ guard let c = ch else { throw HelperError("BAD_ARGS", "unknown key '\(token)' in '\(raw)'") }
468
+ guard let r = resolveChar(c, layout: layout, ansiFallback: true) else {
469
+ throw HelperError("BAD_ARGS", "no key produces '\(c)' on the current keyboard layout")
470
+ }
471
+ combo.keyCode = r.code
472
+ combo.isCharKey = true
473
+ combo.keyName = String(c).lowercased()
474
+ if r.shift && !combo.mods.contains(.shift) { combo.mods.append(.shift) }
475
+ if r.alt && !combo.mods.contains(.alt) { combo.mods.append(.alt) }
476
+ }
477
+ return combo
478
+ }
479
+
480
+ enum TypeSegment {
481
+ case text(String)
482
+ case key(CGKeyCode, String)
483
+
484
+ var utf16Count: Int {
485
+ switch self {
486
+ case .text(let s): return s.utf16.count
487
+ case .key(_, let s): return s.utf16.count
488
+ }
489
+ }
490
+ }
491
+
492
+ /// Newlines become Return and tabs become Tab key presses; everything else is
493
+ /// grouped into chunks of at most `chunk` (≤ 20) UTF-16 units without splitting a
494
+ /// grapheme cluster.
495
+ func segmentText(_ text: String, chunk: Int) -> [TypeSegment] {
496
+ let limit = clampInt(chunk, 1, 20)
497
+ var out: [TypeSegment] = []
498
+ var buf = ""
499
+ var units = 0
500
+ func flush() {
501
+ if !buf.isEmpty { out.append(.text(buf)) }
502
+ buf = ""
503
+ units = 0
504
+ }
505
+ for ch in text {
506
+ if ch == "\n" || ch == "\r\n" || ch == "\r" {
507
+ flush()
508
+ out.append(.key(0x24, String(ch)))
509
+ continue
510
+ }
511
+ if ch == "\t" {
512
+ flush()
513
+ out.append(.key(0x30, "\t"))
514
+ continue
515
+ }
516
+ let n = String(ch).utf16.count
517
+ if units + n > limit { flush() }
518
+ buf.append(ch)
519
+ units += n
520
+ }
521
+ flush()
522
+ return out
523
+ }
524
+
525
+ // MARK: - Configuration
526
+
527
+ struct BlockRule {
528
+ let id: String
529
+ let bundleIds: [String]
530
+ /// Dotted bundle-id prefixes ending in "." (e.g. "com.apple.Safari.WebApp.").
531
+ var bundlePrefixes: [String] = []
532
+ let nameRe: NSRegularExpression?
533
+ let windowTitleRe: NSRegularExpression?
534
+ let reason: String
535
+ }
536
+
537
+ struct ProtectRule {
538
+ let index: Int
539
+ var id: String? = nil
540
+ let bundleIds: [String]
541
+ var bundlePrefixes: [String] = []
542
+ /// At least one of titleRe / appNameRe is set (parseGuard refuses neither).
543
+ let titleRe: NSRegularExpression?
544
+ var appNameRe: NSRegularExpression? = nil
545
+ let reason: String
546
+ }
547
+
548
+ struct GuardConfig {
549
+ var blocked: [BlockRule] = []
550
+ var protectedWindows: [ProtectRule] = []
551
+ var secureField = true
552
+ var refuseWhenLocked = true
553
+ }
554
+
555
+ struct MonitorConfig {
556
+ var armed = false
557
+ var esc = true
558
+ var failsafeCorner = true
559
+ var cornerSizePt = 4.0
560
+ }
561
+
562
+ struct InputConfig {
563
+ var typeChunk = 20
564
+ var typeDelayMs = 10.0
565
+ var moveSteps = 8
566
+ }
567
+
568
+ enum Config {
569
+ private static let lock = NSLock()
570
+ private static var _guard = GuardConfig()
571
+ private static var _monitor = MonitorConfig()
572
+ private static var _input = InputConfig()
573
+
574
+ static var guardCfg: GuardConfig { lock.lock(); defer { lock.unlock() }; return _guard }
575
+ static var monitor: MonitorConfig { lock.lock(); defer { lock.unlock() }; return _monitor }
576
+ static var input: InputConfig { lock.lock(); defer { lock.unlock() }; return _input }
577
+
578
+ static func commit(_ g: GuardConfig, _ m: MonitorConfig, _ i: InputConfig) {
579
+ lock.lock()
580
+ _guard = g
581
+ _monitor = m
582
+ _input = i
583
+ lock.unlock()
584
+ }
585
+ }
586
+
587
+ /// Titles, app names and patterns are compared in NFC: System Settings and web
588
+ /// pages may hand over "Segurança" decomposed (c + U+0327), which ICU would not
589
+ /// match against the precomposed pattern.
590
+ func nfc(_ s: String) -> String { s.precomposedStringWithCanonicalMapping }
591
+
592
+ /// Guard patterns are compiled case-insensitively (ICU syntax — plain JS-style
593
+ /// alternations and classes behave the same). An empty pattern means "absent".
594
+ func compileRegex(_ pattern: Any?, field: String) throws -> NSRegularExpression? {
595
+ guard let p = pattern as? String, !p.isEmpty else { return nil }
596
+ do {
597
+ return try NSRegularExpression(pattern: nfc(p), options: [.caseInsensitive])
598
+ } catch {
599
+ throw HelperError("BAD_ARGS", "\(field) is not a valid regular expression", ["field": field, "pattern": p])
600
+ }
601
+ }
602
+
603
+ func reMatch(_ re: NSRegularExpression, _ s: String) -> Bool {
604
+ re.firstMatch(in: s, options: [], range: NSRange(s.startIndex..<s.endIndex, in: s)) != nil
605
+ }
606
+
607
+ /// Same rule as protocol.js BUNDLE_PREFIX_RE: dotted reverse-DNS, at least two
608
+ /// segments, trailing dot — so a prefix can never equal a bare bundle id.
609
+ let BUNDLE_PREFIX_PATTERN = "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+\\.$"
610
+
611
+ func parsePrefixes(_ v: Any?, field: String) throws -> [String] {
612
+ guard let v = v, !(v is NSNull) else { return [] }
613
+ guard let arr = v as? [Any] else { throw HelperError("BAD_ARGS", "\(field) must be an array", ["field": field]) }
614
+ return try arr.enumerated().map { (i, item) -> String in
615
+ guard let s = item as? String, s.range(of: BUNDLE_PREFIX_PATTERN, options: .regularExpression) != nil else {
616
+ throw HelperError("BAD_ARGS", "\(field)[\(i)] must be a dotted bundle id prefix ending in \".\"", ["field": "\(field)[\(i)]"])
617
+ }
618
+ return s
619
+ }
620
+ }
621
+
622
+ func hasBundlePrefix(_ bundleId: String, _ prefixes: [String]) -> Bool {
623
+ let b = bundleId.lowercased()
624
+ return prefixes.contains { !$0.isEmpty && b.hasPrefix($0.lowercased()) }
625
+ }
626
+
627
+ func parseGuard(_ d: [String: Any], into g: inout GuardConfig) throws {
628
+ if let arr = d["blockedApps"] as? [Any] {
629
+ g.blocked = try arr.enumerated().map { (i, item) -> BlockRule in
630
+ let field = "guard.blockedApps[\(i)]"
631
+ guard let r = item as? [String: Any] else { throw HelperError("BAD_ARGS", "\(field) must be an object") }
632
+ return BlockRule(
633
+ id: (r["id"] as? String) ?? "rule\(i)",
634
+ bundleIds: (r["bundleIds"] as? [Any])?.compactMap { $0 as? String } ?? [],
635
+ bundlePrefixes: try parsePrefixes(r["bundlePrefixes"], field: "\(field).bundlePrefixes"),
636
+ nameRe: try compileRegex(r["nameRe"], field: "\(field).nameRe"),
637
+ windowTitleRe: try compileRegex(r["windowTitleRe"], field: "\(field).windowTitleRe"),
638
+ reason: (r["reason"] as? String) ?? "")
639
+ }
640
+ } else if d["blockedApps"] != nil && !(d["blockedApps"] is NSNull) {
641
+ throw HelperError("BAD_ARGS", "guard.blockedApps must be an array")
642
+ }
643
+ if let arr = d["protectedWindows"] as? [Any] {
644
+ g.protectedWindows = try arr.enumerated().map { (i, item) -> ProtectRule in
645
+ let field = "guard.protectedWindows[\(i)]"
646
+ guard let r = item as? [String: Any] else { throw HelperError("BAD_ARGS", "\(field) must be an object") }
647
+ let titleRe = try compileRegex(r["titleRe"], field: "\(field).titleRe")
648
+ let appNameRe = try compileRegex(r["appNameRe"], field: "\(field).appNameRe")
649
+ guard titleRe != nil || appNameRe != nil else {
650
+ throw HelperError("BAD_ARGS", "\(field).titleRe (or appNameRe) is required")
651
+ }
652
+ return ProtectRule(
653
+ index: i,
654
+ id: r["id"] as? String,
655
+ bundleIds: (r["bundleIds"] as? [Any])?.compactMap { $0 as? String } ?? [],
656
+ bundlePrefixes: try parsePrefixes(r["bundlePrefixes"], field: "\(field).bundlePrefixes"),
657
+ titleRe: titleRe,
658
+ appNameRe: appNameRe,
659
+ reason: (r["reason"] as? String) ?? "")
660
+ }
661
+ } else if d["protectedWindows"] != nil && !(d["protectedWindows"] is NSNull) {
662
+ throw HelperError("BAD_ARGS", "guard.protectedWindows must be an array")
663
+ }
664
+ if let b = Args.bool(d["secureField"]) { g.secureField = b }
665
+ if let b = Args.bool(d["refuseWhenLocked"]) { g.refuseWhenLocked = b }
666
+ }
667
+
668
+ // MARK: - Probe (what an action would hit)
669
+
670
+ struct Probe {
671
+ var pid: Int? = nil
672
+ var bundleId: String? = nil
673
+ var app: String? = nil
674
+ var windowId: Int? = nil
675
+ var windowTitle: String? = nil
676
+ var role: String? = nil
677
+ var subrole: String? = nil
678
+ var title: String? = nil
679
+ var desc: String? = nil
680
+ var valuePreview: String? = nil
681
+ var secure = false
682
+ var enabled: Bool? = nil
683
+ var frame: CGRect? = nil
684
+ /// false when the element's attributes could not be read (hung app, no element).
685
+ var readable = true
686
+ var axError: AXError = .success
687
+
688
+ var json: [String: Any] {
689
+ [
690
+ "pid": pid as Any, "bundleId": bundleId as Any, "app": app as Any,
691
+ "windowId": windowId as Any, "windowTitle": windowTitle as Any,
692
+ "role": role as Any, "subrole": subrole as Any, "title": title as Any,
693
+ "description": desc as Any, "valuePreview": (secure ? nil : valuePreview) as Any,
694
+ "secure": secure, "enabled": enabled as Any, "frame": frame.map { rectJSON($0) } as Any,
695
+ ]
696
+ }
697
+ }
698
+
699
+ func sameId(_ a: String, _ b: String) -> Bool { a.caseInsensitiveCompare(b) == .orderedSame }
700
+
701
+ /// Blocked app: bundle id listed, OR bundle id starts with a listed prefix, OR
702
+ /// app name matches nameRe, OR window title matches windowTitleRe. Protected
703
+ /// window: the app is in scope — bundle id listed or prefixed; no ids and no
704
+ /// prefixes means any app — AND (window title matches titleRe OR app name
705
+ /// matches appNameRe). Names and titles are NFC-normalized first; unknown titles
706
+ /// and names never match. Same semantics as protocol.js matchGuard.
707
+ func matchGuard(_ p: Probe, _ g: GuardConfig, context: String) -> HelperError? {
708
+ let appName = p.app.map(nfc)
709
+ let title = p.windowTitle.map(nfc)
710
+ for r in g.blocked {
711
+ var why: String? = nil
712
+ if let b = p.bundleId, r.bundleIds.contains(where: { sameId($0, b) }) {
713
+ why = "bundleId"
714
+ } else if let b = p.bundleId, hasBundlePrefix(b, r.bundlePrefixes) {
715
+ why = "bundlePrefix"
716
+ } else if let re = r.nameRe, let n = appName, reMatch(re, n) {
717
+ why = "name"
718
+ } else if let re = r.windowTitleRe, let t = title, reMatch(re, t) {
719
+ why = "windowTitle"
720
+ }
721
+ if let why = why {
722
+ let who = p.app ?? p.bundleId ?? "this app"
723
+ return HelperError("BLOCKED_APP", "\(who) is blocked for computer use\(r.reason.isEmpty ? "" : " (\(r.reason))")",
724
+ ["probe": p.json, "rule": ["id": r.id, "reason": r.reason, "matched": why], "context": context])
725
+ }
726
+ }
727
+ for r in g.protectedWindows {
728
+ let scoped = !r.bundleIds.isEmpty || !r.bundlePrefixes.isEmpty
729
+ let appOk = !scoped || (p.bundleId.map { b in r.bundleIds.contains(where: { sameId($0, b) }) || hasBundlePrefix(b, r.bundlePrefixes) } ?? false)
730
+ guard appOk else { continue }
731
+ var why: String? = nil
732
+ if let re = r.titleRe, let t = title, reMatch(re, t) {
733
+ why = "title"
734
+ } else if let re = r.appNameRe, let n = appName, reMatch(re, n) {
735
+ why = "appName"
736
+ }
737
+ if let why = why {
738
+ let what = why == "title" ? "the window \"\(p.windowTitle ?? "")\"" : "\(p.app ?? "this app") (every window)"
739
+ return HelperError("PROTECTED_WINDOW", "\(what) is protected\(r.reason.isEmpty ? "" : " (\(r.reason))")",
740
+ ["probe": p.json, "rule": ["index": r.index, "id": r.id as Any, "reason": r.reason, "matched": why], "context": context])
741
+ }
742
+ }
743
+ return nil
744
+ }
745
+
746
+ // MARK: - Displays
747
+
748
+ enum Displays {
749
+ static func ids() -> [CGDirectDisplayID] {
750
+ var count: UInt32 = 0
751
+ guard CGGetActiveDisplayList(0, nil, &count) == .success, count > 0 else { return [] }
752
+ var list = [CGDirectDisplayID](repeating: 0, count: Int(count))
753
+ guard CGGetActiveDisplayList(count, &list, &count) == .success else { return [] }
754
+ return Array(list.prefix(Int(count)))
755
+ }
756
+
757
+ static func scale(_ id: CGDirectDisplayID) -> Double {
758
+ guard let mode = CGDisplayCopyDisplayMode(id), mode.width > 0 else { return 1 }
759
+ return Double(mode.pixelWidth) / Double(mode.width)
760
+ }
761
+
762
+ static func pixelSize(_ id: CGDirectDisplayID) -> (w: Int, h: Int) {
763
+ let b = CGDisplayBounds(id)
764
+ guard let mode = CGDisplayCopyDisplayMode(id) else { return (Int(b.width), Int(b.height)) }
765
+ return (mode.pixelWidth, mode.pixelHeight)
766
+ }
767
+
768
+ static func names() -> [CGDirectDisplayID: String] {
769
+ onMain {
770
+ var out: [CGDirectDisplayID: String] = [:]
771
+ for screen in NSScreen.screens {
772
+ if let n = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber {
773
+ out[n.uint32Value] = screen.localizedName
774
+ }
775
+ }
776
+ return out
777
+ }
778
+ }
779
+
780
+ /// Main display first, then left-to-right / top-to-bottom. Mirrors are skipped.
781
+ static func list() -> [[String: Any]] {
782
+ let names = names()
783
+ let all = ids().filter { CGDisplayMirrorsDisplay($0) == 0 }
784
+ let sorted = all.sorted { a, b in
785
+ let am = CGDisplayIsMain(a) != 0, bm = CGDisplayIsMain(b) != 0
786
+ if am != bm { return am }
787
+ let ra = CGDisplayBounds(a), rb = CGDisplayBounds(b)
788
+ return ra.minX != rb.minX ? ra.minX < rb.minX : ra.minY < rb.minY
789
+ }
790
+ return sorted.enumerated().map { (i, id) -> [String: Any] in
791
+ let px = pixelSize(id)
792
+ return [
793
+ "id": Int(id), "index": i, "main": CGDisplayIsMain(id) != 0,
794
+ "bounds": rectJSON(CGDisplayBounds(id)), "pixel": ["w": px.w, "h": px.h],
795
+ "scale": scale(id), "asleep": CGDisplayIsAsleep(id) != 0, "name": names[id] as Any,
796
+ ]
797
+ }
798
+ }
799
+
800
+ static func containing(_ p: CGPoint) -> CGDirectDisplayID? {
801
+ var id: CGDirectDisplayID = 0
802
+ var count: UInt32 = 0
803
+ guard CGGetDisplaysWithPoint(p, 1, &id, &count) == .success, count > 0 else { return nil }
804
+ return id
805
+ }
806
+ }
807
+
808
+ // MARK: - Windows (CGWindowList — titles need Screen Recording; never prompts)
809
+
810
+ struct WinInfo {
811
+ let windowId: Int
812
+ let pid: Int
813
+ let owner: String?
814
+ let title: String?
815
+ let bounds: CGRect
816
+ let layer: Int
817
+ let onScreen: Bool
818
+ let alpha: Double
819
+ }
820
+
821
+ enum Windows {
822
+ static func parse(_ raw: [[String: Any]]) -> [WinInfo] {
823
+ raw.compactMap { d in
824
+ guard let num = (d[kCGWindowNumber as String] as? NSNumber)?.intValue,
825
+ let pid = (d[kCGWindowOwnerPID as String] as? NSNumber)?.intValue else { return nil }
826
+ var rect = CGRect.zero
827
+ if let bd = d[kCGWindowBounds as String] as? NSDictionary, let r = CGRect(dictionaryRepresentation: bd as CFDictionary) {
828
+ rect = r
829
+ }
830
+ let title = d[kCGWindowName as String] as? String
831
+ return WinInfo(
832
+ windowId: num, pid: pid, owner: d[kCGWindowOwnerName as String] as? String,
833
+ title: (title?.isEmpty ?? true) ? nil : title, bounds: rect,
834
+ layer: (d[kCGWindowLayer as String] as? NSNumber)?.intValue ?? 0,
835
+ onScreen: (d[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue ?? false,
836
+ alpha: (d[kCGWindowAlpha as String] as? NSNumber)?.doubleValue ?? 1)
837
+ }
838
+ }
839
+
840
+ static func list(onScreenOnly: Bool) -> [WinInfo] {
841
+ let opts: CGWindowListOption = onScreenOnly ? [.optionOnScreenOnly, .excludeDesktopElements] : [.optionAll, .excludeDesktopElements]
842
+ guard let raw = CGWindowListCopyWindowInfo(opts, 0) as? [[String: Any]] else { return [] }
843
+ return parse(raw)
844
+ }
845
+
846
+ static func info(_ windowId: Int) -> WinInfo? {
847
+ guard windowId > 0, windowId <= Int(UInt32.max),
848
+ let raw = CGWindowListCopyWindowInfo([.optionIncludingWindow], CGWindowID(windowId)) as? [[String: Any]] else { return nil }
849
+ return parse(raw).first { $0.windowId == windowId }
850
+ }
851
+
852
+ /// Front-most visible window under a point (optionally owned by pid).
853
+ static func at(_ p: CGPoint, pid: Int? = nil) -> WinInfo? {
854
+ list(onScreenOnly: true).first { w in
855
+ w.alpha > 0 && w.owner != "Window Server" && w.bounds.contains(p) && (pid == nil || w.pid == pid!)
856
+ }
857
+ }
858
+ }
859
+
860
+ func appInfo(_ pid: pid_t) -> (bundleId: String?, name: String?) {
861
+ guard let app = NSRunningApplication(processIdentifier: pid) else { return (nil, nil) }
862
+ return (app.bundleIdentifier, app.localizedName)
863
+ }
864
+
865
+ func frontmostJSON() -> Any {
866
+ guard let app = NSWorkspace.shared.frontmostApplication else { return NSNull() }
867
+ return ["pid": Int(app.processIdentifier), "bundleId": app.bundleIdentifier as Any, "name": app.localizedName as Any]
868
+ }
869
+
870
+ // MARK: - Session, permissions, responsible app
871
+
872
+ func sessionInfo() -> (locked: Bool, onConsole: Bool) {
873
+ // No dictionary → not inside a GUI login session: treat as off-console (fail closed).
874
+ guard let d = CGSessionCopyCurrentDictionary() as? [String: Any] else { return (false, false) }
875
+ let locked = (d["CGSSessionScreenIsLocked"] as? NSNumber)?.boolValue ?? false
876
+ let onConsole = (d[kCGSessionOnConsoleKey] as? NSNumber)?.boolValue ?? true
877
+ return (locked, onConsole)
878
+ }
879
+
880
+ func permissionFlags() -> [String: Bool] {
881
+ [
882
+ "screenRecording": CGPreflightScreenCaptureAccess(),
883
+ "accessibility": AXIsProcessTrusted(),
884
+ "postEvents": CGPreflightPostEventAccess(),
885
+ "inputMonitoring": CGPreflightListenEventAccess(),
886
+ ]
887
+ }
888
+
889
+ func permissionsJSON() -> [String: Any] {
890
+ var out: [String: Any] = permissionFlags()
891
+ out["responsibleApp"] = ResponsibleApp.info ?? NSNull()
892
+ return out
893
+ }
894
+
895
+ enum ResponsibleApp {
896
+ /// The app macOS attributes our TCC permissions to: the nearest ancestor process
897
+ /// whose executable lives inside a .app bundle (outermost bundle of its path).
898
+ static let info: [String: Any]? = compute()
899
+
900
+ static func parentPid(_ pid: pid_t) -> pid_t? {
901
+ var kinfo = kinfo_proc()
902
+ var size = MemoryLayout<kinfo_proc>.stride
903
+ var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
904
+ guard sysctl(&mib, UInt32(mib.count), &kinfo, &size, nil, 0) == 0, size > 0 else { return nil }
905
+ return kinfo.kp_eproc.e_ppid
906
+ }
907
+
908
+ static func path(_ pid: pid_t) -> String? {
909
+ var buf = [CChar](repeating: 0, count: 4096)
910
+ guard proc_pidpath(pid, &buf, UInt32(buf.count)) > 0 else { return nil }
911
+ return String(cString: buf)
912
+ }
913
+
914
+ static func compute() -> [String: Any]? {
915
+ var pid = getppid()
916
+ var hops = 0
917
+ while pid > 1 && hops < 64 {
918
+ if let p = path(pid), let r = p.range(of: ".app/") {
919
+ let bundlePath = String(p[..<r.lowerBound]) + ".app"
920
+ let bundle = Bundle(path: bundlePath)
921
+ let bid = bundle?.bundleIdentifier
922
+ let name = (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
923
+ ?? (bundle?.object(forInfoDictionaryKey: "CFBundleName") as? String)
924
+ ?? URL(fileURLWithPath: bundlePath).deletingPathExtension().lastPathComponent
925
+ var rpid = Int(pid)
926
+ if let bid = bid {
927
+ let running = NSRunningApplication.runningApplications(withBundleIdentifier: bid)
928
+ if let app = running.first(where: { $0.bundleURL?.path == bundlePath }) ?? running.first {
929
+ rpid = Int(app.processIdentifier)
930
+ }
931
+ }
932
+ return ["name": name, "bundleId": bid as Any, "path": bundlePath, "pid": rpid]
933
+ }
934
+ guard let pp = parentPid(pid), pp != pid else { break }
935
+ pid = pp
936
+ hops += 1
937
+ }
938
+ return nil
939
+ }
940
+ }
941
+
942
+ func sessionStateJSON() -> [String: Any] {
943
+ let s = sessionInfo()
944
+ return ["locked": s.locked, "onConsole": s.onConsole, "secureInput": IsSecureEventInputEnabled(), "frontmost": frontmostJSON()]
945
+ }
946
+
947
+ // MARK: - Accessibility
948
+
949
+ enum AX {
950
+ static let systemWide = AXUIElementCreateSystemWide()
951
+ private static var prepared = false
952
+
953
+ /// Bound every AX round-trip so one hung app can't wedge the action queue.
954
+ static func prepare() {
955
+ if prepared { return }
956
+ prepared = true
957
+ AXUIElementSetMessagingTimeout(systemWide, 1.5)
958
+ }
959
+
960
+ static func copy(_ el: AXUIElement, _ attr: String) -> (AXError, CFTypeRef?) {
961
+ var v: CFTypeRef?
962
+ let e = AXUIElementCopyAttributeValue(el, attr as CFString, &v)
963
+ return (e, v)
964
+ }
965
+
966
+ static func string(_ el: AXUIElement, _ attr: String) -> String? {
967
+ let (e, v) = copy(el, attr)
968
+ return e == .success ? v as? String : nil
969
+ }
970
+
971
+ static func element(_ v: CFTypeRef?) -> AXUIElement? {
972
+ guard let v = v, CFGetTypeID(v) == AXUIElementGetTypeID() else { return nil }
973
+ return (v as! AXUIElement)
974
+ }
975
+
976
+ /// Missing attributes come back as nil (AX packs them as kAXValueAXErrorType).
977
+ static func multi(_ el: AXUIElement, _ attrs: [String]) -> (AXError, [CFTypeRef?]) {
978
+ var out: CFArray?
979
+ let e = AXUIElementCopyMultipleAttributeValues(el, attrs as CFArray, AXCopyMultipleAttributeOptions(rawValue: 0), &out)
980
+ guard e == .success, let arr = out as [AnyObject]?, arr.count == attrs.count else {
981
+ return (e == .success ? .failure : e, Array(repeating: nil, count: attrs.count))
982
+ }
983
+ return (.success, arr.map { v -> CFTypeRef? in
984
+ if CFGetTypeID(v) == AXValueGetTypeID(), AXValueGetType(v as! AXValue) == .axError { return nil }
985
+ if v is NSNull { return nil }
986
+ return v
987
+ })
988
+ }
989
+
990
+ static func point(_ v: CFTypeRef?) -> CGPoint? {
991
+ guard let v = v, CFGetTypeID(v) == AXValueGetTypeID() else { return nil }
992
+ var p = CGPoint.zero
993
+ return AXValueGetValue(v as! AXValue, .cgPoint, &p) ? p : nil
994
+ }
995
+
996
+ static func size(_ v: CFTypeRef?) -> CGSize? {
997
+ guard let v = v, CFGetTypeID(v) == AXValueGetTypeID() else { return nil }
998
+ var s = CGSize.zero
999
+ return AXValueGetValue(v as! AXValue, .cgSize, &s) ? s : nil
1000
+ }
1001
+
1002
+ static func frame(_ el: AXUIElement) -> CGRect? {
1003
+ let (_, vals) = multi(el, ["AXPosition", "AXSize"])
1004
+ guard let p = point(vals[0]), let s = size(vals[1]) else { return nil }
1005
+ return CGRect(origin: p, size: s)
1006
+ }
1007
+
1008
+ static func actions(_ el: AXUIElement) -> [String] {
1009
+ var names: CFArray?
1010
+ guard AXUIElementCopyActionNames(el, &names) == .success, let list = names as? [String] else { return [] }
1011
+ return list
1012
+ }
1013
+
1014
+ static func children(_ el: AXUIElement, limit: Int) -> [AXUIElement] {
1015
+ var out: CFArray?
1016
+ guard AXUIElementCopyAttributeValues(el, "AXChildren" as CFString, 0, limit, &out) == .success,
1017
+ let arr = out as [AnyObject]? else { return [] }
1018
+ return arr.compactMap { element($0) }
1019
+ }
1020
+
1021
+ static func pid(_ el: AXUIElement) -> pid_t? {
1022
+ var pid: pid_t = 0
1023
+ return AXUIElementGetPid(el, &pid) == .success && pid > 0 ? pid : nil
1024
+ }
1025
+
1026
+ static func setBool(_ el: AXUIElement, _ attr: String, _ value: Bool) -> AXError {
1027
+ AXUIElementSetAttributeValue(el, attr as CFString, (value ? kCFBooleanTrue : kCFBooleanFalse)!)
1028
+ }
1029
+
1030
+ static func perform(_ el: AXUIElement, _ action: String) -> AXError {
1031
+ AXUIElementPerformAction(el, action as CFString)
1032
+ }
1033
+ }
1034
+
1035
+ func axFail(_ e: AXError, _ what: String) -> HelperError {
1036
+ switch e {
1037
+ case .apiDisabled: return HelperError("NOT_TRUSTED", "Accessibility permission is not granted")
1038
+ case .invalidUIElement: return HelperError("REF_EXPIRED", "\(what): the element no longer exists")
1039
+ case .cannotComplete: return HelperError("AX_TIMEOUT", "\(what): the application did not answer in time")
1040
+ case .notImplemented: return HelperError("UNSUPPORTED", "\(what): the application does not implement it")
1041
+ default: return HelperError("AX_ERROR", "\(what) failed (AXError \(e.rawValue))", ["axError": Int(e.rawValue)])
1042
+ }
1043
+ }
1044
+
1045
+ typealias AXGetWindowFn = @convention(c) (AXUIElement, UnsafeMutablePointer<CGWindowID>) -> AXError
1046
+ /// _AXUIElementGetWindow is private but stable for over a decade (window managers
1047
+ /// rely on it). Resolved dynamically so its absence only costs the frame fallback.
1048
+ let axGetWindow: AXGetWindowFn? = {
1049
+ guard let h = dlopen(nil, RTLD_NOW), let sym = dlsym(h, "_AXUIElementGetWindow") else { return nil }
1050
+ return unsafeBitCast(sym, to: AXGetWindowFn.self)
1051
+ }()
1052
+
1053
+ func windowID(of win: AXUIElement, pid: pid_t) -> Int? {
1054
+ if let f = axGetWindow {
1055
+ var wid: CGWindowID = 0
1056
+ if f(win, &wid) == .success, wid != 0 { return Int(wid) }
1057
+ }
1058
+ guard let fr = AX.frame(win) else { return nil }
1059
+ let near = { (a: CGFloat, b: CGFloat) in abs(a - b) <= 2 }
1060
+ return Windows.list(onScreenOnly: false).first { w in
1061
+ w.pid == Int(pid) && w.layer == 0 && near(w.bounds.minX, fr.minX) && near(w.bounds.minY, fr.minY)
1062
+ && near(w.bounds.width, fr.width) && near(w.bounds.height, fr.height)
1063
+ }?.windowId
1064
+ }
1065
+
1066
+ let TEXT_ROLES: Set<String> = ["AXTextField", "AXTextArea", "AXComboBox"]
1067
+
1068
+ /// Never called for secure fields. Long text is read through AXStringForRange so a
1069
+ /// big document isn't copied just to show a preview.
1070
+ func readValue(_ el: AXUIElement, role: String?, limit: Int) -> Any? {
1071
+ if let role = role, TEXT_ROLES.contains(role) {
1072
+ var range = CFRange(location: 0, length: limit)
1073
+ if let param = AXValueCreate(.cfRange, &range) {
1074
+ var v: CFTypeRef?
1075
+ if AXUIElementCopyParameterizedAttributeValue(el, "AXStringForRange" as CFString, param, &v) == .success, let s = v as? String {
1076
+ return s
1077
+ }
1078
+ }
1079
+ }
1080
+ let (e, v) = AX.copy(el, "AXValue")
1081
+ guard e == .success, let v = v else { return nil }
1082
+ if let s = v as? String { return s.count > limit ? String(s.prefix(limit)) : s }
1083
+ if CFGetTypeID(v) == CFBooleanGetTypeID() { return (v as! NSNumber).boolValue }
1084
+ if let n = v as? NSNumber { return n }
1085
+ return nil
1086
+ }
1087
+
1088
+ let PROBE_ATTRS = ["AXRole", "AXSubrole", "AXTitle", "AXDescription", "AXEnabled", "AXPosition", "AXSize", "AXWindow"]
1089
+
1090
+ func probeElement(_ el: AXUIElement) -> Probe {
1091
+ var p = Probe()
1092
+ if let pid = AX.pid(el) {
1093
+ p.pid = Int(pid)
1094
+ let info = appInfo(pid)
1095
+ p.bundleId = info.bundleId
1096
+ p.app = info.name
1097
+ }
1098
+ let (err, vals) = AX.multi(el, PROBE_ATTRS)
1099
+ p.axError = err
1100
+ guard err == .success else {
1101
+ p.readable = false
1102
+ return p
1103
+ }
1104
+ p.role = vals[0] as? String
1105
+ p.subrole = vals[1] as? String
1106
+ p.title = trimmed(vals[2] as? String, 200)
1107
+ p.desc = trimmed(vals[3] as? String, 200)
1108
+ p.enabled = (vals[4] as? NSNumber)?.boolValue
1109
+ if let pt = AX.point(vals[5]), let sz = AX.size(vals[6]) { p.frame = CGRect(origin: pt, size: sz) }
1110
+ p.secure = p.subrole == "AXSecureTextField" || p.role == "AXSecureTextField"
1111
+ if p.role == nil && p.subrole == nil { p.readable = false }
1112
+ if !p.secure, let v = readValue(el, role: p.role, limit: 80) {
1113
+ p.valuePreview = (v as? String) ?? (v as? NSNumber)?.stringValue ?? (v as? Bool).map { $0 ? "true" : "false" }
1114
+ }
1115
+ let win: AXUIElement? = p.role == "AXWindow" ? el : AX.element(vals[7])
1116
+ if let win = win {
1117
+ p.windowTitle = AX.string(win, "AXTitle").flatMap { $0.isEmpty ? nil : $0 }
1118
+ if let pid = p.pid { p.windowId = windowID(of: win, pid: pid_t(pid)) }
1119
+ }
1120
+ return p
1121
+ }
1122
+
1123
+ func probeAt(_ pt: CGPoint) throws -> Probe {
1124
+ AX.prepare()
1125
+ var found: AXUIElement?
1126
+ let err = AXUIElementCopyElementAtPosition(AX.systemWide, Float(pt.x), Float(pt.y), &found)
1127
+ if err == .apiDisabled { throw axFail(err, "probe") }
1128
+ var p = Probe()
1129
+ if err == .success, let el = found {
1130
+ p = probeElement(el)
1131
+ } else {
1132
+ p.readable = false
1133
+ p.axError = err
1134
+ }
1135
+ // Fill in from the window list when AX couldn't (hung or AX-less apps) so the
1136
+ // guard still sees which app and window sit under the point.
1137
+ if p.pid == nil || p.windowId == nil || p.windowTitle == nil {
1138
+ if let w = Windows.at(pt, pid: p.pid) {
1139
+ if p.pid == nil {
1140
+ p.pid = w.pid
1141
+ let info = appInfo(pid_t(w.pid))
1142
+ p.bundleId = info.bundleId
1143
+ p.app = info.name ?? w.owner
1144
+ }
1145
+ if p.windowId == nil { p.windowId = w.windowId }
1146
+ if p.windowTitle == nil { p.windowTitle = w.title }
1147
+ if p.frame == nil { p.frame = w.bounds }
1148
+ }
1149
+ }
1150
+ return p
1151
+ }
1152
+
1153
+ /// The frontmost app's focused window (role AXWindow), used for keyboard guards.
1154
+ func frontmostWindowProbe() -> Probe? {
1155
+ guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
1156
+ var p = Probe()
1157
+ p.pid = Int(app.processIdentifier)
1158
+ p.bundleId = app.bundleIdentifier
1159
+ p.app = app.localizedName
1160
+ let axApp = AXUIElementCreateApplication(app.processIdentifier)
1161
+ let (e, v) = AX.copy(axApp, "AXFocusedWindow")
1162
+ if e == .success, let win = AX.element(v) {
1163
+ p.role = "AXWindow"
1164
+ p.windowTitle = AX.string(win, "AXTitle").flatMap { $0.isEmpty ? nil : $0 }
1165
+ p.windowId = windowID(of: win, pid: app.processIdentifier)
1166
+ p.frame = AX.frame(win)
1167
+ } else {
1168
+ p.readable = false
1169
+ p.axError = e
1170
+ }
1171
+ return p
1172
+ }
1173
+
1174
+ func probeFocus() throws -> Probe {
1175
+ AX.prepare()
1176
+ let (e, v) = AX.copy(AX.systemWide, "AXFocusedUIElement")
1177
+ if e == .apiDisabled { throw axFail(e, "probe_focus") }
1178
+ if e == .success, let el = AX.element(v) { return probeElement(el) }
1179
+ var p = frontmostWindowProbe() ?? Probe()
1180
+ p.readable = false
1181
+ return p
1182
+ }
1183
+
1184
+ // MARK: - Input
1185
+
1186
+ enum Btn: String, CaseIterable {
1187
+ case left, right, middle
1188
+
1189
+ var cg: CGMouseButton {
1190
+ switch self {
1191
+ case .left: return .left
1192
+ case .right: return .right
1193
+ case .middle: return .center
1194
+ }
1195
+ }
1196
+ var down: CGEventType {
1197
+ switch self {
1198
+ case .left: return .leftMouseDown
1199
+ case .right: return .rightMouseDown
1200
+ case .middle: return .otherMouseDown
1201
+ }
1202
+ }
1203
+ var up: CGEventType {
1204
+ switch self {
1205
+ case .left: return .leftMouseUp
1206
+ case .right: return .rightMouseUp
1207
+ case .middle: return .otherMouseUp
1208
+ }
1209
+ }
1210
+ var dragged: CGEventType {
1211
+ switch self {
1212
+ case .left: return .leftMouseDragged
1213
+ case .right: return .rightMouseDragged
1214
+ case .middle: return .otherMouseDragged
1215
+ }
1216
+ }
1217
+ }
1218
+
1219
+ func cursorLocation() -> CGPoint { CGEvent(source: nil)?.location ?? .zero }
1220
+
1221
+ final class Input {
1222
+ static let shared = Input()
1223
+
1224
+ /// One private source: our modifier state never mixes with the user's, and
1225
+ /// local-event suppression is off so the user can always take over.
1226
+ let source: CGEventSource?
1227
+ private let lock = NSLock()
1228
+ private var gen: UInt64 = 0
1229
+ private var heldButtons: [Btn] = []
1230
+ private var heldKeys: Set<CGKeyCode> = []
1231
+ private var latched = false
1232
+ private var lastUserInterruptNs: UInt64 = 0
1233
+
1234
+ init() {
1235
+ source = CGEventSource(stateID: .privateState)
1236
+ source?.localEventsSuppressionInterval = 0
1237
+ source?.userData = SYNB_STAMP
1238
+ }
1239
+
1240
+ var generation: UInt64 { lock.lock(); defer { lock.unlock() }; return gen }
1241
+ var isLatched: Bool { lock.lock(); defer { lock.unlock() }; return latched }
1242
+
1243
+ func setLatched(_ v: Bool) { lock.lock(); latched = v; lock.unlock() }
1244
+
1245
+ func noteUserInterrupt(_ ns: UInt64) { lock.lock(); lastUserInterruptNs = ns; lock.unlock() }
1246
+
1247
+ func userInterrupted(since ns: UInt64) -> Bool { lock.lock(); defer { lock.unlock() }; return lastUserInterruptNs > ns }
1248
+
1249
+ func checkAbort(_ g: UInt64) throws {
1250
+ if generation != g { throw HelperError("ABORTED", "the action was aborted") }
1251
+ }
1252
+
1253
+ func sleepAbortable(_ ms: Double, _ g: UInt64) throws {
1254
+ let end = nowNs() + UInt64(max(0, ms) * 1_000_000)
1255
+ while nowNs() < end {
1256
+ try checkAbort(g)
1257
+ sleepMs(min(20, Double(end - nowNs()) / 1_000_000))
1258
+ }
1259
+ }
1260
+
1261
+ func post(_ e: CGEvent?) {
1262
+ guard let e = e else { return }
1263
+ e.setIntegerValueField(.eventSourceUserData, value: SYNB_STAMP)
1264
+ e.post(tap: .cghidEventTap)
1265
+ }
1266
+
1267
+ // ── held-state tracking (so abort / panic / exit can release it) ──
1268
+ private func mark(button b: Btn, _ down: Bool) {
1269
+ lock.lock()
1270
+ if down { if !heldButtons.contains(b) { heldButtons.append(b) } } else { heldButtons.removeAll { $0 == b } }
1271
+ lock.unlock()
1272
+ }
1273
+
1274
+ private func mark(key k: CGKeyCode, _ down: Bool) {
1275
+ lock.lock()
1276
+ if down { heldKeys.insert(k) } else { heldKeys.remove(k) }
1277
+ lock.unlock()
1278
+ }
1279
+
1280
+ var heldButton: Btn? { lock.lock(); defer { lock.unlock() }; return heldButtons.last }
1281
+
1282
+ func isHeld(_ b: Btn) -> Bool { lock.lock(); defer { lock.unlock() }; return heldButtons.contains(b) }
1283
+
1284
+ /// Releases exactly what this process holds. Posts nothing when nothing is held.
1285
+ func releaseTracked() {
1286
+ lock.lock()
1287
+ let buttons = heldButtons
1288
+ let keys = heldKeys
1289
+ heldButtons = []
1290
+ heldKeys = []
1291
+ lock.unlock()
1292
+ guard !buttons.isEmpty || !keys.isEmpty, CGPreflightPostEventAccess() else { return }
1293
+ let pos = cursorLocation()
1294
+ for b in buttons { post(CGEvent(mouseEventSource: source, mouseType: b.up, mouseCursorPosition: pos, mouseButton: b.cg)) }
1295
+ for k in keys { keyEvent(k, down: false, flags: []) }
1296
+ }
1297
+
1298
+ /// Cancels the in-flight action (it notices at its next check) and releases held input.
1299
+ func abort() {
1300
+ lock.lock()
1301
+ gen &+= 1
1302
+ lock.unlock()
1303
+ releaseTracked()
1304
+ }
1305
+
1306
+ /// After a respawn the previous process's held state is unknown: besides what we
1307
+ /// track, release every modifier and mouse button the session reports as down.
1308
+ /// Nothing is posted when nothing is down.
1309
+ func panic() {
1310
+ abort()
1311
+ guard CGPreflightPostEventAccess() else { return }
1312
+ let flags = CGEventSource.flagsState(.combinedSessionState)
1313
+ for m in Mod.allCases where flags.contains(m.flag) {
1314
+ keyEvent(m.keyCode, down: false, flags: [])
1315
+ if let r = m.rightKeyCode { keyEvent(r, down: false, flags: []) }
1316
+ }
1317
+ let pos = cursorLocation()
1318
+ for b in Btn.allCases where CGEventSource.buttonState(.combinedSessionState, button: b.cg) {
1319
+ post(CGEvent(mouseEventSource: source, mouseType: b.up, mouseCursorPosition: pos, mouseButton: b.cg))
1320
+ }
1321
+ }
1322
+
1323
+ // ── pointer ──
1324
+ func postPointer(_ pt: CGPoint) {
1325
+ let held = heldButton
1326
+ post(CGEvent(mouseEventSource: source, mouseType: held?.dragged ?? .mouseMoved, mouseCursorPosition: pt, mouseButton: held?.cg ?? .left))
1327
+ }
1328
+
1329
+ func move(to target: CGPoint, steps: Int, gen g: UInt64) throws {
1330
+ let start = cursorLocation()
1331
+ let n = clampInt(steps, 1, 200)
1332
+ for i in 1...n {
1333
+ try checkAbort(g)
1334
+ let t = CGFloat(i) / CGFloat(n)
1335
+ postPointer(CGPoint(x: start.x + (target.x - start.x) * t, y: start.y + (target.y - start.y) * t))
1336
+ if i < n { sleepMs(4) }
1337
+ }
1338
+ }
1339
+
1340
+ func button(_ b: Btn, down: Bool, at p: CGPoint, clickState: Int = 1, flags: CGEventFlags = []) {
1341
+ let e = CGEvent(mouseEventSource: source, mouseType: down ? b.down : b.up, mouseCursorPosition: p, mouseButton: b.cg)
1342
+ e?.setIntegerValueField(.mouseEventClickState, value: Int64(clickState))
1343
+ e?.flags = flags
1344
+ if down { mark(button: b, true) }
1345
+ post(e)
1346
+ if !down { mark(button: b, false) }
1347
+ }
1348
+
1349
+ func click(at p: CGPoint, button b: Btn, count: Int, flags: CGEventFlags, gen g: UInt64) throws {
1350
+ for c in 1...count {
1351
+ try checkAbort(g)
1352
+ button(b, down: true, at: p, clickState: c, flags: flags)
1353
+ sleepMs(15)
1354
+ button(b, down: false, at: p, clickState: c, flags: flags)
1355
+ if c < count { sleepMs(35) }
1356
+ }
1357
+ }
1358
+
1359
+ func scroll(at p: CGPoint, dx: Int, dy: Int, flags: CGEventFlags, gen g: UInt64) throws {
1360
+ let (w1, w2) = scrollWheelDeltas(dx: dx, dy: dy)
1361
+ var r1 = Int(w1), r2 = Int(w2)
1362
+ // Several small events instead of one huge delta: apps clamp or accelerate big ones.
1363
+ while r1 != 0 || r2 != 0 {
1364
+ try checkAbort(g)
1365
+ let s1 = clampInt(r1, -5, 5), s2 = clampInt(r2, -5, 5)
1366
+ let e = CGEvent(scrollWheelEvent2Source: source, units: .line, wheelCount: 2, wheel1: Int32(s1), wheel2: Int32(s2), wheel3: 0)
1367
+ e?.location = p
1368
+ e?.flags = flags
1369
+ post(e)
1370
+ r1 -= s1
1371
+ r2 -= s2
1372
+ if r1 != 0 || r2 != 0 { sleepMs(15) }
1373
+ }
1374
+ }
1375
+
1376
+ // ── keyboard ──
1377
+ func keyEvent(_ code: CGKeyCode, down: Bool, flags: CGEventFlags) {
1378
+ let e = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: down)
1379
+ e?.flags = flags
1380
+ post(e)
1381
+ }
1382
+
1383
+ func tap(_ code: CGKeyCode, flags: CGEventFlags = []) {
1384
+ mark(key: code, true)
1385
+ keyEvent(code, down: true, flags: flags)
1386
+ sleepMs(4)
1387
+ keyEvent(code, down: false, flags: flags)
1388
+ mark(key: code, false)
1389
+ }
1390
+
1391
+ /// Modifiers go down in order (as real key events carrying the accumulated flags),
1392
+ /// the key is pressed `repeatCount` times (held for holdMs when given), then the
1393
+ /// modifiers are released in reverse — also when aborted.
1394
+ func pressCombo(_ combo: KeyCombo, repeatCount: Int, holdMs: Double, gen g: UInt64) throws {
1395
+ var flags = CGEventFlags()
1396
+ var pressed: [Mod] = []
1397
+ defer {
1398
+ for m in pressed.reversed() {
1399
+ flags.remove(m.flag)
1400
+ keyEvent(m.keyCode, down: false, flags: flags)
1401
+ mark(key: m.keyCode, false)
1402
+ }
1403
+ }
1404
+ for m in combo.mods {
1405
+ try checkAbort(g)
1406
+ flags.insert(m.flag)
1407
+ mark(key: m.keyCode, true)
1408
+ keyEvent(m.keyCode, down: true, flags: flags)
1409
+ pressed.append(m)
1410
+ sleepMs(6)
1411
+ }
1412
+ if let code = combo.keyCode {
1413
+ let n = clampInt(repeatCount, 1, 100)
1414
+ for i in 0..<n {
1415
+ try checkAbort(g)
1416
+ mark(key: code, true)
1417
+ keyEvent(code, down: true, flags: flags)
1418
+ do {
1419
+ if holdMs > 0 { try sleepAbortable(holdMs, g) } else { sleepMs(8) }
1420
+ } catch {
1421
+ keyEvent(code, down: false, flags: flags)
1422
+ mark(key: code, false)
1423
+ throw error
1424
+ }
1425
+ keyEvent(code, down: false, flags: flags)
1426
+ mark(key: code, false)
1427
+ if i < n - 1 { sleepMs(25) }
1428
+ }
1429
+ } else if holdMs > 0 {
1430
+ try sleepAbortable(holdMs, g)
1431
+ }
1432
+ }
1433
+
1434
+ /// CGEventKeyboardSetUnicodeString carries at most 20 UTF-16 units per event;
1435
+ /// longer strings are split without breaking a surrogate pair.
1436
+ func typeUnicode(_ s: String) {
1437
+ let units = Array(s.utf16)
1438
+ var idx = 0
1439
+ while idx < units.count {
1440
+ var end = min(idx + 20, units.count)
1441
+ if end < units.count, end - idx > 1, UTF16.isLeadSurrogate(units[end - 1]) { end -= 1 }
1442
+ var slice = Array(units[idx..<end])
1443
+ let down = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true)
1444
+ down?.flags = []
1445
+ down?.keyboardSetUnicodeString(stringLength: slice.count, unicodeString: &slice)
1446
+ post(down)
1447
+ let up = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false)
1448
+ up?.flags = []
1449
+ up?.keyboardSetUnicodeString(stringLength: slice.count, unicodeString: &slice)
1450
+ post(up)
1451
+ idx = end
1452
+ }
1453
+ }
1454
+
1455
+ /// keys mode: real key codes for the current layout; characters the layout can't
1456
+ /// produce fall back to the unicode path.
1457
+ func typeKeys(_ s: String, layout: LayoutMap) {
1458
+ for ch in s {
1459
+ if let r = resolveChar(ch, layout: layout, ansiFallback: false) {
1460
+ var f = CGEventFlags()
1461
+ if r.shift { f.insert(.maskShift) }
1462
+ if r.alt { f.insert(.maskAlternate) }
1463
+ tap(r.code, flags: f)
1464
+ sleepMs(4)
1465
+ } else {
1466
+ typeUnicode(String(ch))
1467
+ }
1468
+ }
1469
+ }
1470
+ }
1471
+
1472
+ /// Reverse map of the current keyboard layout (UCKeyTranslate over every key code
1473
+ /// and the none/shift/option/shift+option states). TIS must run on the main thread.
1474
+ func currentLayoutMap() -> LayoutMap {
1475
+ onMain {
1476
+ var map: LayoutMap = [:]
1477
+ guard let src = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(),
1478
+ let raw = TISGetInputSourceProperty(src, kTISPropertyUnicodeKeyLayoutData) else { return map }
1479
+ let data = Unmanaged<CFData>.fromOpaque(raw).takeUnretainedValue()
1480
+ guard let bytes = CFDataGetBytePtr(data) else { return map }
1481
+ let layout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
1482
+ let kbType = UInt32(LMGetKbdType())
1483
+ for state: UInt32 in [0, 2, 8, 10] {
1484
+ for code in 0..<128 {
1485
+ var dead: UInt32 = 0
1486
+ var len = 0
1487
+ var chars = [UniChar](repeating: 0, count: 8)
1488
+ let st = UCKeyTranslate(layout, UInt16(code), UInt16(kUCKeyActionDown), state, kbType,
1489
+ OptionBits(kUCKeyTranslateNoDeadKeysMask), &dead, chars.count, &len, &chars)
1490
+ guard st == noErr, len > 0 else { continue }
1491
+ let s = String(utf16CodeUnits: chars, count: len)
1492
+ guard let first = s.unicodeScalars.first, !CharacterSet.controlCharacters.contains(first) else { continue }
1493
+ if map[s] == nil { map[s] = (CGKeyCode(code), state) }
1494
+ }
1495
+ }
1496
+ return map
1497
+ }
1498
+ }
1499
+
1500
+ func parseButton(_ s: String?) throws -> Btn {
1501
+ guard let s = s else { return .left }
1502
+ guard let b = Btn(rawValue: s.lowercased()) else { throw HelperError("BAD_ARGS", "button must be left, right or middle") }
1503
+ return b
1504
+ }
1505
+
1506
+ func parseModifiers(_ arr: [Any]?) throws -> CGEventFlags {
1507
+ var f = CGEventFlags()
1508
+ for item in arr ?? [] {
1509
+ guard let s = item as? String, let m = Mod.parse(s) else { throw HelperError("BAD_ARGS", "unknown modifier \(item)") }
1510
+ f.insert(m.flag)
1511
+ }
1512
+ return f
1513
+ }
1514
+
1515
+ // MARK: - Guards
1516
+
1517
+ func requireTrusted() throws {
1518
+ guard AXIsProcessTrusted() else {
1519
+ throw HelperError("NOT_TRUSTED", "Accessibility permission is required", ["responsibleApp": ResponsibleApp.info as Any])
1520
+ }
1521
+ }
1522
+
1523
+ func requireNotLatched() throws {
1524
+ if Input.shared.isLatched {
1525
+ throw HelperError("INTERRUPTED", "an emergency stop is latched; send configure to resume", ["latched": true])
1526
+ }
1527
+ }
1528
+
1529
+ func requireUnlocked() throws {
1530
+ guard Config.guardCfg.refuseWhenLocked else { return }
1531
+ let s = sessionInfo()
1532
+ if s.locked || !s.onConsole {
1533
+ throw HelperError("SCREEN_LOCKED", s.locked ? "the screen is locked" : "the login session is not on the console",
1534
+ ["locked": s.locked, "onConsole": s.onConsole])
1535
+ }
1536
+ }
1537
+
1538
+ func requireOnScreen(_ p: CGPoint) throws {
1539
+ if Displays.containing(p) == nil {
1540
+ throw HelperError("OUT_OF_BOUNDS", "(\(p.x), \(p.y)) is not on any display", ["point": pointJSON(p)])
1541
+ }
1542
+ }
1543
+
1544
+ /// Every input/AX action: permission, emergency latch, lock state.
1545
+ func preInput() throws {
1546
+ try requireTrusted()
1547
+ try requireNotLatched()
1548
+ try requireUnlocked()
1549
+ }
1550
+
1551
+ func guardPoint(_ p: CGPoint, context: String) throws -> Probe {
1552
+ let probe = try probeAt(p)
1553
+ if let e = matchGuard(probe, Config.guardCfg, context: context) { throw e }
1554
+ return probe
1555
+ }
1556
+
1557
+ /// Keyboard target: the focused element and the frontmost window must pass the
1558
+ /// app/window rules; printable input (or paste) must not reach a secure field, and
1559
+ /// fails closed when focus is unreadable while secure event input is on.
1560
+ func keyboardGuard(printable: Bool, context: String) throws -> Probe {
1561
+ try requireUnlocked()
1562
+ let g = Config.guardCfg
1563
+ let focus = try probeFocus()
1564
+ if let e = matchGuard(focus, g, context: context) { throw e }
1565
+ if let front = frontmostWindowProbe(), let e = matchGuard(front, g, context: context) { throw e }
1566
+ if printable && g.secureField {
1567
+ if focus.secure {
1568
+ throw HelperError("SECURE_FIELD", "the focused field is a password field", ["probe": focus.json, "context": context])
1569
+ }
1570
+ if !focus.readable && IsSecureEventInputEnabled() {
1571
+ throw HelperError("SECURE_FIELD", "secure input is active and the focused element can't be read",
1572
+ ["probe": focus.json, "context": context, "secureInput": true])
1573
+ }
1574
+ }
1575
+ return focus
1576
+ }
1577
+
1578
+ // MARK: - Screen capture (ScreenCaptureKit only; never called without the grant)
1579
+
1580
+ func requireCapture() throws {
1581
+ guard CGPreflightScreenCaptureAccess() else {
1582
+ throw HelperError("NO_SCREEN_RECORDING", "Screen Recording permission is required", ["responsibleApp": ResponsibleApp.info as Any])
1583
+ }
1584
+ }
1585
+
1586
+ func mapCaptureError(_ e: Error) -> HelperError {
1587
+ if let h = e as? HelperError { return h }
1588
+ let ns = e as NSError
1589
+ if ns.domain == SCStreamErrorDomain && ns.code == -3801 {
1590
+ return HelperError("NO_SCREEN_RECORDING", "Screen Recording permission was declined")
1591
+ }
1592
+ return HelperError("INTERNAL", "screen capture failed: \(ns.localizedDescription)", ["domain": ns.domain, "code": ns.code])
1593
+ }
1594
+
1595
+ func shareableDisplay(_ id: CGDirectDisplayID) throws -> SCDisplay {
1596
+ let content: SCShareableContent
1597
+ do {
1598
+ content = try waitFor("ScreenCaptureKit", timeout: 10) { done in
1599
+ SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: true) { c, e in done(c, e) }
1600
+ }
1601
+ } catch { throw mapCaptureError(error) }
1602
+ guard let d = content.displays.first(where: { $0.displayID == id }) else {
1603
+ throw HelperError("BAD_ARGS", "display \(id) is not available for capture")
1604
+ }
1605
+ return d
1606
+ }
1607
+
1608
+ func sckCapture(_ filter: SCContentFilter, _ config: SCStreamConfiguration) throws -> CGImage {
1609
+ do {
1610
+ return try waitFor("ScreenCaptureKit", timeout: 10) { done in
1611
+ SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) { img, e in done(img, e) }
1612
+ }
1613
+ } catch { throw mapCaptureError(error) }
1614
+ }
1615
+
1616
+ func scaleImage(_ img: CGImage, _ w: Int, _ h: Int) -> CGImage? {
1617
+ guard let cs = CGColorSpace(name: CGColorSpace.sRGB),
1618
+ let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0, space: cs,
1619
+ bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue) else { return nil }
1620
+ ctx.interpolationQuality = .high
1621
+ ctx.draw(img, in: CGRect(x: 0, y: 0, width: w, height: h))
1622
+ return ctx.makeImage()
1623
+ }
1624
+
1625
+ func encodeJPEG(_ img: CGImage, quality: Double) throws -> Data {
1626
+ let data = NSMutableData()
1627
+ guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, "public.jpeg" as CFString, 1, nil) else {
1628
+ throw HelperError("INTERNAL", "JPEG encoder unavailable")
1629
+ }
1630
+ CGImageDestinationAddImage(dest, img, [kCGImageDestinationLossyCompressionQuality: clampDouble(quality, 0.05, 1)] as CFDictionary)
1631
+ guard CGImageDestinationFinalize(dest) else { throw HelperError("INTERNAL", "JPEG encoding failed") }
1632
+ return data as Data
1633
+ }
1634
+
1635
+ func parseFit(_ a: Args, defaultW: Double, defaultH: Double) -> (w: Double, h: Double) {
1636
+ let f = a.dict("fit")
1637
+ return (Args.num(f?["w"]) ?? defaultW, Args.num(f?["h"]) ?? defaultH)
1638
+ }
1639
+
1640
+ func imageJSON(_ img: CGImage, quality: Double) throws -> [String: Any] {
1641
+ let jpeg = try encodeJPEG(img, quality: quality)
1642
+ return ["w": img.width, "h": img.height, "mime": "image/jpeg", "data": jpeg.base64EncodedString()]
1643
+ }
1644
+
1645
+ func cmdScreenshot(_ a: Args) throws -> Any {
1646
+ try requireCapture()
1647
+ let did = a.num("displayId").map { CGDirectDisplayID(UInt32(clamping: toInt($0))) } ?? CGMainDisplayID()
1648
+ let bounds = CGDisplayBounds(did)
1649
+ guard bounds.width > 0, bounds.height > 0, Displays.ids().contains(did) else {
1650
+ throw HelperError("BAD_ARGS", "unknown display \(did)")
1651
+ }
1652
+ let scale = Displays.scale(did)
1653
+ let fit = parseFit(a, defaultW: Double(bounds.width), defaultH: Double(bounds.height))
1654
+ let size = fitSize(width: Double(bounds.width), height: Double(bounds.height), fitW: fit.w, fitH: fit.h, scale: scale)
1655
+ let display = try shareableDisplay(did)
1656
+ let filter = SCContentFilter(display: display, excludingWindows: [])
1657
+ let cfg = SCStreamConfiguration()
1658
+ cfg.width = size.w // SCK scales on the GPU to exactly this size
1659
+ cfg.height = size.h
1660
+ cfg.showsCursor = a.bool("showCursor") ?? false
1661
+ cfg.capturesAudio = false
1662
+ cfg.pixelFormat = kCVPixelFormatType_32BGRA
1663
+ cfg.colorSpaceName = CGColorSpace.sRGB
1664
+ cfg.captureResolution = .best
1665
+ var img = try sckCapture(filter, cfg)
1666
+ if img.width != size.w || img.height != size.h, let scaled = scaleImage(img, size.w, size.h) { img = scaled }
1667
+ let px = Displays.pixelSize(did)
1668
+ return [
1669
+ "displayId": Int(did), "bounds": rectJSON(bounds), "capturedPixels": ["w": px.w, "h": px.h],
1670
+ "image": try imageJSON(img, quality: a.num("quality") ?? 0.8),
1671
+ "cursor": pointJSON(cursorLocation()), "frontmost": frontmostJSON(), "capturedAt": epochMs(),
1672
+ ]
1673
+ }
1674
+
1675
+ /// A region at native resolution, downscaled only to stay within fit. The rect is
1676
+ /// clipped to the display under its centre.
1677
+ func cmdCaptureRect(_ a: Args) throws -> Any {
1678
+ try requireCapture()
1679
+ let rect = try Args.rect(a.dict("rect"), "rect")
1680
+ guard let did = Displays.containing(CGPoint(x: rect.midX, y: rect.midY)) else {
1681
+ throw HelperError("OUT_OF_BOUNDS", "the rect's centre is not on any display", ["rect": rectJSON(rect)])
1682
+ }
1683
+ let dbounds = CGDisplayBounds(did)
1684
+ let clipped = rect.intersection(dbounds)
1685
+ guard !clipped.isNull, clipped.width >= 1, clipped.height >= 1 else {
1686
+ throw HelperError("OUT_OF_BOUNDS", "the rect does not overlap a display", ["rect": rectJSON(rect)])
1687
+ }
1688
+ let scale = Displays.scale(did)
1689
+ let fit = parseFit(a, defaultW: Double(clipped.width) * scale, defaultH: Double(clipped.height) * scale)
1690
+ let size = fitSize(width: Double(clipped.width), height: Double(clipped.height), fitW: fit.w, fitH: fit.h, scale: scale)
1691
+ var img: CGImage
1692
+ if #available(macOS 15.2, *) {
1693
+ do {
1694
+ img = try waitFor("ScreenCaptureKit", timeout: 10) { done in
1695
+ SCScreenshotManager.captureImage(in: clipped) { i, e in done(i, e) }
1696
+ }
1697
+ } catch { throw mapCaptureError(error) }
1698
+ } else {
1699
+ let display = try shareableDisplay(did)
1700
+ let filter = SCContentFilter(display: display, excludingWindows: [])
1701
+ let cfg = SCStreamConfiguration()
1702
+ cfg.sourceRect = clipped.offsetBy(dx: -dbounds.minX, dy: -dbounds.minY) // display-local points
1703
+ cfg.width = size.w
1704
+ cfg.height = size.h
1705
+ cfg.showsCursor = false
1706
+ cfg.capturesAudio = false
1707
+ cfg.pixelFormat = kCVPixelFormatType_32BGRA
1708
+ cfg.colorSpaceName = CGColorSpace.sRGB
1709
+ cfg.captureResolution = .best
1710
+ img = try sckCapture(filter, cfg)
1711
+ }
1712
+ if img.width != size.w || img.height != size.h, let scaled = scaleImage(img, size.w, size.h) { img = scaled }
1713
+ return ["image": try imageJSON(img, quality: a.num("quality") ?? 0.85), "rect": rectJSON(clipped), "capturedAt": epochMs()]
1714
+ }
1715
+
1716
+ // MARK: - Pointer / keyboard commands
1717
+
1718
+ func cmdMove(_ a: Args) throws -> Any {
1719
+ try preInput()
1720
+ let p = try a.point()
1721
+ try requireOnScreen(p)
1722
+ // With a button held (mouse_down) a move is a drag: the drop target is guarded.
1723
+ if Input.shared.heldButton != nil { _ = try guardPoint(p, context: "move.drag") }
1724
+ try Input.shared.move(to: p, steps: a.int("steps") ?? Config.input.moveSteps, gen: Input.shared.generation)
1725
+ return [String: Any]()
1726
+ }
1727
+
1728
+ func cmdClick(_ a: Args) throws -> Any {
1729
+ try preInput()
1730
+ let p = try a.point()
1731
+ try requireOnScreen(p)
1732
+ let b = try parseButton(a.string("button"))
1733
+ let count = clampInt(a.int("count") ?? 1, 1, 3)
1734
+ let flags = try parseModifiers(a.array("modifiers"))
1735
+ let g = Input.shared.generation
1736
+ _ = try guardPoint(p, context: "click") // refuse before the pointer moves…
1737
+ try Input.shared.move(to: p, steps: Config.input.moveSteps, gen: g)
1738
+ try requireUnlocked()
1739
+ let target = try guardPoint(p, context: "click") // …and again right before the button goes down
1740
+ try Input.shared.checkAbort(g)
1741
+ try Input.shared.click(at: p, button: b, count: count, flags: flags, gen: g)
1742
+ return ["target": target.json]
1743
+ }
1744
+
1745
+ func cmdMouseDown(_ a: Args) throws -> Any {
1746
+ try preInput()
1747
+ let p = try a.point()
1748
+ try requireOnScreen(p)
1749
+ let b = try parseButton(a.string("button"))
1750
+ let g = Input.shared.generation
1751
+ _ = try guardPoint(p, context: "mouse_down")
1752
+ try Input.shared.move(to: p, steps: Config.input.moveSteps, gen: g)
1753
+ let target = try guardPoint(p, context: "mouse_down")
1754
+ try Input.shared.checkAbort(g)
1755
+ Input.shared.button(b, down: true, at: p)
1756
+ return ["target": target.json]
1757
+ }
1758
+
1759
+ /// No guard: refusing a release would leave the button stuck.
1760
+ func cmdMouseUp(_ a: Args) throws -> Any {
1761
+ try requireTrusted()
1762
+ let p = try a.point()
1763
+ try requireOnScreen(p)
1764
+ let b = try parseButton(a.string("button"))
1765
+ Input.shared.postPointer(p)
1766
+ sleepMs(8)
1767
+ let target = (try? probeAt(p)) ?? Probe()
1768
+ Input.shared.button(b, down: false, at: p)
1769
+ return ["target": target.json]
1770
+ }
1771
+
1772
+ func cmdDrag(_ a: Args) throws -> Any {
1773
+ try preInput()
1774
+ let from = try Args.point(a.dict("from"), "from")
1775
+ let to = try Args.point(a.dict("to"), "to")
1776
+ try requireOnScreen(from)
1777
+ try requireOnScreen(to)
1778
+ let b = try parseButton(a.string("button"))
1779
+ let steps = clampInt(a.int("steps") ?? 20, 1, 400)
1780
+ let g = Input.shared.generation
1781
+ _ = try guardPoint(from, context: "drag.from")
1782
+ _ = try guardPoint(to, context: "drag.to")
1783
+ try Input.shared.move(to: from, steps: Config.input.moveSteps, gen: g)
1784
+ let target = try guardPoint(from, context: "drag.from")
1785
+ try Input.shared.checkAbort(g)
1786
+ Input.shared.button(b, down: true, at: from)
1787
+ do {
1788
+ sleepMs(50)
1789
+ for i in 1...steps {
1790
+ try Input.shared.checkAbort(g)
1791
+ let t = CGFloat(i) / CGFloat(steps)
1792
+ Input.shared.postPointer(CGPoint(x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t))
1793
+ sleepMs(8)
1794
+ }
1795
+ sleepMs(30)
1796
+ // The drop target could have changed while dragging: cancel back at the origin.
1797
+ if let e = matchGuard(try probeAt(to), Config.guardCfg, context: "drag.to") {
1798
+ Input.shared.postPointer(from)
1799
+ sleepMs(20)
1800
+ Input.shared.button(b, down: false, at: from)
1801
+ throw e
1802
+ }
1803
+ Input.shared.button(b, down: false, at: to)
1804
+ } catch {
1805
+ if Input.shared.isHeld(b) { Input.shared.button(b, down: false, at: cursorLocation()) }
1806
+ throw error
1807
+ }
1808
+ return ["target": target.json]
1809
+ }
1810
+
1811
+ func cmdScroll(_ a: Args) throws -> Any {
1812
+ try preInput()
1813
+ let p = try a.point()
1814
+ try requireOnScreen(p)
1815
+ if let u = a.string("units"), u != "line" { throw HelperError("BAD_ARGS", "units must be 'line'") }
1816
+ let dx = clampInt(a.int("dx") ?? 0, -1000, 1000), dy = clampInt(a.int("dy") ?? 0, -1000, 1000)
1817
+ let flags = try parseModifiers(a.array("modifiers"))
1818
+ let g = Input.shared.generation
1819
+ _ = try guardPoint(p, context: "scroll")
1820
+ try Input.shared.move(to: p, steps: Config.input.moveSteps, gen: g)
1821
+ let target = try guardPoint(p, context: "scroll")
1822
+ try Input.shared.scroll(at: p, dx: dx, dy: dy, flags: flags, gen: g)
1823
+ return ["target": target.json]
1824
+ }
1825
+
1826
+ func cmdType(_ a: Args) throws -> Any {
1827
+ try preInput()
1828
+ guard let text = a.string("text") else { throw HelperError("BAD_ARGS", "text is required") }
1829
+ let mode = a.string("mode") ?? "unicode"
1830
+ guard mode == "unicode" || mode == "keys" else { throw HelperError("BAD_ARGS", "mode must be 'unicode' or 'keys'") }
1831
+ let cfg = Config.input
1832
+ let g = Input.shared.generation
1833
+ let started = nowNs()
1834
+ _ = try keyboardGuard(printable: true, context: "type")
1835
+ let layout: LayoutMap = mode == "keys" ? currentLayoutMap() : [:]
1836
+ var typed = 0 // UTF-16 units, so the caller can `text.slice(typed)` what's left
1837
+ for (i, seg) in segmentText(text, chunk: cfg.typeChunk).enumerated() {
1838
+ if Input.shared.generation != g { return ["typed": typed, "interrupted": "abort"] }
1839
+ if Input.shared.userInterrupted(since: started) { return ["typed": typed, "interrupted": "user_input"] }
1840
+ if i > 0 {
1841
+ // Focus can move mid-text (a Tab into a password field): re-check every chunk.
1842
+ do { _ = try keyboardGuard(printable: true, context: "type") } catch let e as HelperError { throw e.adding(["typed": typed]) }
1843
+ }
1844
+ switch seg {
1845
+ case .text(let s):
1846
+ if mode == "keys" { Input.shared.typeKeys(s, layout: layout) } else { Input.shared.typeUnicode(s) }
1847
+ case .key(let code, _):
1848
+ Input.shared.tap(code)
1849
+ }
1850
+ typed += seg.utf16Count
1851
+ sleepMs(cfg.typeDelayMs)
1852
+ }
1853
+ return ["typed": typed]
1854
+ }
1855
+
1856
+ func cmdKey(_ a: Args, hold: Bool) throws -> Any {
1857
+ try preInput()
1858
+ guard let raw = a.string("combo") else { throw HelperError("BAD_ARGS", "combo is required") }
1859
+ let combo = try parseCombo(raw, layout: currentLayoutMap())
1860
+ let holdMs = hold ? clampDouble(a.num("durationMs") ?? 500, 0, 30_000) : 0
1861
+ let target = try keyboardGuard(printable: combo.needsSecureCheck, context: hold ? "hold_key" : "key")
1862
+ try Input.shared.pressCombo(combo, repeatCount: hold ? 1 : (a.int("repeat") ?? 1), holdMs: holdMs, gen: Input.shared.generation)
1863
+ return ["target": target.json]
1864
+ }
1865
+
1866
+ // MARK: - Apps and windows
1867
+
1868
+ func cmdApps() -> Any {
1869
+ NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == .regular }.map { app -> [String: Any] in
1870
+ [
1871
+ "pid": Int(app.processIdentifier), "bundleId": app.bundleIdentifier as Any, "name": app.localizedName as Any,
1872
+ "active": app.isActive, "hidden": app.isHidden, "path": app.bundleURL?.path as Any,
1873
+ ]
1874
+ }
1875
+ }
1876
+
1877
+ func cmdWindows(_ a: Args) -> Any {
1878
+ let pidFilter = a.int("pid")
1879
+ var cache: [Int: (String?, String?)] = [:]
1880
+ return Windows.list(onScreenOnly: a.bool("onScreenOnly") ?? true)
1881
+ .filter { pidFilter == nil || $0.pid == pidFilter! }
1882
+ .map { w -> [String: Any] in
1883
+ if cache[w.pid] == nil { let i = appInfo(pid_t(w.pid)); cache[w.pid] = (i.bundleId, i.name) }
1884
+ let info = cache[w.pid]!
1885
+ let appName: String? = info.1 ?? w.owner
1886
+ return [
1887
+ "windowId": w.windowId, "pid": w.pid, "app": appName as Any, "bundleId": info.0 as Any,
1888
+ "title": w.title as Any, "bounds": rectJSON(w.bounds), "layer": w.layer, "onScreen": w.onScreen,
1889
+ ]
1890
+ }
1891
+ }
1892
+
1893
+ /// Names and bundle ids only — never URLs, paths or anything a shell would expand.
1894
+ func validateAppToken(_ s: String, field: String) throws {
1895
+ let lower = s.lowercased()
1896
+ if s.isEmpty || s.count > 200 || lower.contains("://") || s.contains("/") || s.contains(":") || s.contains("..")
1897
+ || s.hasPrefix("~") || s.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) {
1898
+ throw HelperError("BAD_ARGS", "\(field) must be an app name or bundle id, not a URL or path")
1899
+ }
1900
+ if lower.range(of: "^[a-z][a-z0-9+.-]*:", options: .regularExpression) != nil {
1901
+ throw HelperError("BAD_ARGS", "\(field) looks like a URL")
1902
+ }
1903
+ }
1904
+
1905
+ func resolveAppURL(bundleId: String?, name: String?) -> URL? {
1906
+ if let b = bundleId { return NSWorkspace.shared.urlForApplication(withBundleIdentifier: b) }
1907
+ guard let n = name else { return nil }
1908
+ if let running = NSWorkspace.shared.runningApplications.first(where: { ($0.localizedName ?? "").caseInsensitiveCompare(n) == .orderedSame }),
1909
+ let url = running.bundleURL {
1910
+ return url
1911
+ }
1912
+ let fm = FileManager.default
1913
+ let dirs = ["/Applications", "/Applications/Utilities", "/System/Applications", "/System/Applications/Utilities",
1914
+ NSHomeDirectory() + "/Applications", "/System/Library/CoreServices"]
1915
+ let wanted = "\(n).app"
1916
+ for d in dirs where fm.fileExists(atPath: "\(d)/\(wanted)") { return URL(fileURLWithPath: "\(d)/\(wanted)") }
1917
+ for d in dirs {
1918
+ if let items = try? fm.contentsOfDirectory(atPath: d), let hit = items.first(where: { $0.caseInsensitiveCompare(wanted) == .orderedSame }) {
1919
+ return URL(fileURLWithPath: "\(d)/\(hit)")
1920
+ }
1921
+ }
1922
+ return nil
1923
+ }
1924
+
1925
+ func cmdOpenApp(_ a: Args) throws -> Any {
1926
+ try requireNotLatched()
1927
+ try requireUnlocked()
1928
+ let bundleId = a.string("bundleId"), name = a.string("name")
1929
+ guard bundleId != nil || name != nil else { throw HelperError("BAD_ARGS", "bundleId or name is required") }
1930
+ if let b = bundleId { try validateAppToken(b, field: "bundleId") }
1931
+ if let n = name { try validateAppToken(n, field: "name") }
1932
+ guard let url = resolveAppURL(bundleId: bundleId, name: name) else {
1933
+ throw HelperError("APP_NOT_FOUND", "no application \(bundleId ?? name ?? "")", ["bundleId": bundleId as Any, "name": name as Any])
1934
+ }
1935
+ let bundle = Bundle(url: url)
1936
+ var probe = Probe()
1937
+ probe.bundleId = bundle?.bundleIdentifier ?? bundleId
1938
+ probe.app = (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
1939
+ ?? (bundle?.object(forInfoDictionaryKey: "CFBundleName") as? String) ?? url.deletingPathExtension().lastPathComponent
1940
+ if let e = matchGuard(probe, Config.guardCfg, context: "open_app") { throw e }
1941
+ let cfg = NSWorkspace.OpenConfiguration()
1942
+ cfg.activates = true
1943
+ let app: NSRunningApplication = try waitFor("open_app", timeout: 20) { done in
1944
+ NSWorkspace.shared.openApplication(at: url, configuration: cfg) { app, e in done(app, e) }
1945
+ }
1946
+ let bundleIdOut: String? = app.bundleIdentifier ?? probe.bundleId
1947
+ return ["pid": Int(app.processIdentifier), "bundleId": bundleIdOut as Any]
1948
+ }
1949
+
1950
+ func axWindow(pid: pid_t, windowId: Int) -> AXUIElement? {
1951
+ let axApp = AXUIElementCreateApplication(pid)
1952
+ let (e, v) = AX.copy(axApp, "AXWindows")
1953
+ guard e == .success, let arr = v as? [AnyObject] else { return nil }
1954
+ for item in arr {
1955
+ if let win = AX.element(item), windowID(of: win, pid: pid) == windowId { return win }
1956
+ }
1957
+ return nil
1958
+ }
1959
+
1960
+ func cmdFocusApp(_ a: Args) throws -> Any {
1961
+ try requireNotLatched()
1962
+ try requireUnlocked()
1963
+ let windowId = a.int("windowId")
1964
+ var app: NSRunningApplication? = nil
1965
+ if let pid = a.int("pid") {
1966
+ app = NSRunningApplication(processIdentifier: pid_t(clamping: pid))
1967
+ } else if let b = a.string("bundleId") {
1968
+ app = NSRunningApplication.runningApplications(withBundleIdentifier: b).first
1969
+ } else if let wid = windowId, let w = Windows.info(wid) {
1970
+ app = NSRunningApplication(processIdentifier: pid_t(w.pid))
1971
+ }
1972
+ guard let target = app else { throw HelperError("APP_NOT_FOUND", "no running application matches", a.raw) }
1973
+ var probe = Probe()
1974
+ probe.pid = Int(target.processIdentifier)
1975
+ probe.bundleId = target.bundleIdentifier
1976
+ probe.app = target.localizedName
1977
+ if let wid = windowId { probe.windowId = wid; probe.windowTitle = Windows.info(wid)?.title }
1978
+ if let e = matchGuard(probe, Config.guardCfg, context: "focus_app") { throw e }
1979
+ onMain {
1980
+ _ = target.unhide()
1981
+ _ = target.activate(options: [.activateAllWindows])
1982
+ }
1983
+ if AXIsProcessTrusted() {
1984
+ AX.prepare()
1985
+ let axApp = AXUIElementCreateApplication(target.processIdentifier)
1986
+ _ = AX.setBool(axApp, "AXFrontmost", true) // works for background callers under cooperative activation
1987
+ if let wid = windowId, let win = axWindow(pid: target.processIdentifier, windowId: wid) {
1988
+ _ = AX.perform(win, "AXRaise")
1989
+ _ = AX.setBool(win, "AXMain", true)
1990
+ }
1991
+ }
1992
+ let deadline = nowNs() + 1_000_000_000
1993
+ while nowNs() < deadline && NSWorkspace.shared.frontmostApplication?.processIdentifier != target.processIdentifier { sleepMs(50) }
1994
+ return [
1995
+ "pid": Int(target.processIdentifier), "bundleId": target.bundleIdentifier as Any,
1996
+ "frontmost": NSWorkspace.shared.frontmostApplication?.processIdentifier == target.processIdentifier,
1997
+ ]
1998
+ }
1999
+
2000
+ // MARK: - AX snapshot / action
2001
+
2002
+ enum Snapshots {
2003
+ /// Last 4 snapshots, each holding its retained elements. Only the action queue touches this.
2004
+ private static var items: [(id: String, elements: [AXUIElement])] = []
2005
+ private static var counter = 0
2006
+
2007
+ static func add(_ elements: [AXUIElement]) -> String {
2008
+ counter += 1
2009
+ let id = "s\(counter)"
2010
+ items.append((id, elements))
2011
+ if items.count > 4 { items.removeFirst(items.count - 4) }
2012
+ return id
2013
+ }
2014
+
2015
+ static func element(_ snapshotId: String, _ ref: String) -> AXUIElement? {
2016
+ guard let snap = items.first(where: { $0.id == snapshotId }), ref.hasPrefix("e"), let n = Int(ref.dropFirst()),
2017
+ n >= 1, n <= snap.elements.count else { return nil }
2018
+ return snap.elements[n - 1]
2019
+ }
2020
+ }
2021
+
2022
+ let INTERACTIVE_ROLES: Set<String> = [
2023
+ "AXButton", "AXCheckBox", "AXRadioButton", "AXTextField", "AXTextArea", "AXComboBox", "AXPopUpButton",
2024
+ "AXMenuButton", "AXSlider", "AXLink", "AXMenuItem", "AXMenuBarItem", "AXDisclosureTriangle", "AXIncrementor",
2025
+ "AXColorWell", "AXDockItem", "AXRow", "AXTab", "AXSearchField",
2026
+ ]
2027
+ let INTERACTIVE_ACTIONS: Set<String> = ["AXPress", "AXConfirm", "AXPick", "AXIncrement", "AXDecrement", "AXShowMenu", "AXOpen"]
2028
+ let ACTION_NAMES: [String: String] = [
2029
+ "AXPress": "press", "AXShowMenu": "show_menu", "AXRaise": "raise", "AXScrollToVisible": "scroll_into_view",
2030
+ "AXConfirm": "confirm", "AXPick": "select", "AXIncrement": "increment", "AXDecrement": "decrement", "AXCancel": "cancel",
2031
+ ]
2032
+ /// One multi-attribute call per element. 0-7 since protocol 1, 8-12 since protocol 2.
2033
+ let SNAPSHOT_ATTRS = [
2034
+ "AXRole", "AXSubrole", "AXTitle", "AXDescription", "AXEnabled", "AXFocused", "AXPosition", "AXSize",
2035
+ "AXHelp", "AXPlaceholderValue", "AXIdentifier", "AXTitleUIElement", "AXModal",
2036
+ ]
2037
+ /// Labelled containers name the `group` of everything below them.
2038
+ let GROUP_ROLES: Set<String> = [
2039
+ "AXGroup", "AXToolbar", "AXSheet", "AXTabGroup", "AXSplitGroup", "AXScrollArea", "AXOutline", "AXTable",
2040
+ "AXList", "AXRadioGroup", "AXPopover", "AXBrowser",
2041
+ ]
2042
+ /// These name the group even without a label ("toolbar", "sheet", "popover").
2043
+ let BARE_GROUP_ROLES: Set<String> = ["AXToolbar", "AXSheet", "AXPopover"]
2044
+ let DIALOG_SUBROLES: Set<String> = ["AXDialog", "AXSystemDialog"]
2045
+ /// Unnamed rows and cells (and AXOutlineRow subroles) get a contentLabel with enrich.
2046
+ let ROW_ROLES: Set<String> = ["AXRow", "AXCell"]
2047
+
2048
+ /// Whitespace runs (newlines included) collapsed to one space; nil when blank.
2049
+ func cleanLabel(_ s: String?) -> String? {
2050
+ guard let s = s else { return nil }
2051
+ let out = s.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.joined(separator: " ")
2052
+ return out.isEmpty ? nil : out
2053
+ }
2054
+
2055
+ /// The `group` handed to an element's children: "<role> <label>" (role without
2056
+ /// AX, lowercased; clipped to 60) for a labelled container, the bare role for an
2057
+ /// unlabelled toolbar / sheet / popover, else the inherited group. A window is
2058
+ /// never a group, so window titles never leak through this field.
2059
+ func groupForChildren(role: String?, title: String?, desc: String?, inherited: String?) -> String? {
2060
+ guard let r = role, GROUP_ROLES.contains(r) else { return inherited }
2061
+ let name = String(r.dropFirst(2)).lowercased()
2062
+ if let label = cleanLabel(title) ?? cleanLabel(desc) { return String("\(name) \(label)".prefix(60)) }
2063
+ return BARE_GROUP_ROLES.contains(r) ? name : inherited
2064
+ }
2065
+
2066
+ /// enrich: the label of an element's AXTitleUIElement (its AXValue, else AXTitle), ≤ 80.
2067
+ func titleElementLabel(_ el: AXUIElement?) -> String? {
2068
+ guard let el = el else { return nil }
2069
+ let (e, v) = AX.multi(el, ["AXValue", "AXTitle"])
2070
+ guard e == .success else { return nil }
2071
+ return (cleanLabel(v[0] as? String) ?? cleanLabel(v[1] as? String)).map { String($0.prefix(80)) }
2072
+ }
2073
+
2074
+ /// enrich: an unnamed row / cell is named by the first AXStaticText value within
2075
+ /// two levels below it (breadth first, ≤ 8 children per element), ≤ 80.
2076
+ func contentLabel(_ el: AXUIElement, deadline: UInt64) -> String? {
2077
+ var level = AX.children(el, limit: 8)
2078
+ for depth in 0..<2 {
2079
+ var next: [AXUIElement] = []
2080
+ for c in level {
2081
+ if nowNs() > deadline { return nil }
2082
+ guard let r = AX.string(c, "AXRole") else { continue }
2083
+ if r == "AXStaticText", let s = cleanLabel(AX.string(c, "AXValue")) { return String(s.prefix(80)) }
2084
+ if depth == 0 { next.append(contentsOf: AX.children(c, limit: 8)) }
2085
+ }
2086
+ level = next
2087
+ }
2088
+ return nil
2089
+ }
2090
+
2091
+ /// enrich: the snapshot's root window as {id, title, subrole, modal}, or null.
2092
+ func rootWindowJSON(_ root: AXUIElement?, pid: pid_t) -> Any {
2093
+ guard let w = root else { return NSNull() }
2094
+ let (e, v) = AX.multi(w, ["AXTitle", "AXSubrole", "AXModal"])
2095
+ guard e == .success else { return NSNull() }
2096
+ return [
2097
+ "id": windowID(of: w, pid: pid) as Any, "title": trimmed(v[0] as? String, 1000) as Any,
2098
+ "subrole": (v[1] as? String) as Any, "modal": (v[2] as? NSNumber)?.boolValue ?? false,
2099
+ ]
2100
+ }
2101
+
2102
+ /// The app's UI language as a primary subtag ("pt"): the first of the bundle's
2103
+ /// localizations in the user's preference order ("Base" skipped; legacy names
2104
+ /// such as "English" canonicalized). nil when unknown.
2105
+ func appLanguage(_ running: NSRunningApplication) -> String? {
2106
+ guard let url = running.bundleURL, let bundle = Bundle(url: url) else { return nil }
2107
+ for loc in bundle.preferredLocalizations + [bundle.developmentLocalization].compactMap({ $0 }) where loc.lowercased() != "base" {
2108
+ let canonical = Locale.canonicalLanguageIdentifier(from: loc)
2109
+ if let primary = canonical.split(whereSeparator: { $0 == "-" || $0 == "_" }).first, !primary.isEmpty {
2110
+ return primary.lowercased()
2111
+ }
2112
+ }
2113
+ return nil
2114
+ }
2115
+
2116
+ /// One pending element of the snapshot walk, with what it inherits from above.
2117
+ struct AXVisit {
2118
+ let el: AXUIElement
2119
+ let depth: Int
2120
+ let parent: String?
2121
+ let group: String?
2122
+ let modal: Bool
2123
+ let web: Bool
2124
+ }
2125
+
2126
+ func cmdAxSnapshot(_ a: Args) throws -> Any {
2127
+ try requireTrusted()
2128
+ try requireUnlocked()
2129
+ AX.prepare()
2130
+ let depthMax = clampInt(a.int("depth") ?? 12, 1, 40)
2131
+ let maxNodes = clampInt(a.int("maxNodes") ?? 400, 1, 5000)
2132
+ let interactiveOnly = a.bool("interactiveOnly") ?? true
2133
+ let timeoutMs = clampDouble(a.num("timeoutMs") ?? 4000, 100, 30_000)
2134
+ // Intent snapshots: titleElement / contentLabel per node; window, app.lang and
2135
+ // up to maxTexts static texts (≤ 200 chars each) on the result.
2136
+ let enrich = a.bool("enrich") ?? false
2137
+ let maxTexts = clampInt(a.int("maxTexts") ?? 80, 0, 500)
2138
+
2139
+ // Target: an explicit window, else the given (or frontmost) app's focused window.
2140
+ var pid: pid_t
2141
+ var root: AXUIElement? = nil
2142
+ if let wid = a.int("windowId") {
2143
+ guard let w = Windows.info(wid) else { throw HelperError("APP_NOT_FOUND", "window \(wid) not found", ["windowId": wid]) }
2144
+ pid = pid_t(w.pid)
2145
+ root = axWindow(pid: pid, windowId: wid)
2146
+ if root == nil { throw HelperError("AX_ERROR", "window \(wid) is not exposed to accessibility", ["windowId": wid]) }
2147
+ } else if let p = a.int("pid") {
2148
+ pid = pid_t(clamping: p)
2149
+ } else {
2150
+ guard let front = NSWorkspace.shared.frontmostApplication else { throw HelperError("APP_NOT_FOUND", "no frontmost application") }
2151
+ pid = front.processIdentifier
2152
+ }
2153
+ guard let running = NSRunningApplication(processIdentifier: pid) else { throw HelperError("APP_NOT_FOUND", "no process \(pid)") }
2154
+ let appEl = AXUIElementCreateApplication(pid)
2155
+ AXUIElementSetMessagingTimeout(appEl, Float(min(1.0, timeoutMs / 1000)))
2156
+ if root == nil {
2157
+ for attr in ["AXFocusedWindow", "AXMainWindow"] {
2158
+ let (e, v) = AX.copy(appEl, attr)
2159
+ if e == .cannotComplete { throw axFail(e, "ax_snapshot") }
2160
+ if let w = AX.element(v) { root = w; break }
2161
+ }
2162
+ }
2163
+ let start = root ?? appEl
2164
+ let deadline = nowNs() + UInt64(timeoutMs * 1_000_000)
2165
+
2166
+ var nodes: [[String: Any]] = []
2167
+ var elements: [AXUIElement] = []
2168
+ var texts: [String] = []
2169
+ var truncated = false
2170
+ var visited = 0
2171
+ var stack: [AXVisit] = [AXVisit(el: start, depth: 0, parent: nil, group: nil, modal: false, web: false)]
2172
+ while let item = stack.popLast() {
2173
+ if nodes.count >= maxNodes || nowNs() > deadline || visited >= maxNodes * 25 { truncated = true; break }
2174
+ visited += 1
2175
+ autoreleasepool {
2176
+ let (err, vals) = AX.multi(item.el, SNAPSHOT_ATTRS)
2177
+ if err != .success {
2178
+ if visited == 1 && err == .cannotComplete { truncated = true }
2179
+ return
2180
+ }
2181
+ let role = vals[0] as? String
2182
+ let subrole = vals[1] as? String
2183
+ let title = trimmed(vals[2] as? String, 200)
2184
+ let desc = trimmed(vals[3] as? String, 200)
2185
+ let secure = subrole == "AXSecureTextField" || role == "AXSecureTextField"
2186
+ // modal: at or below a sheet, a popover, a dialog window or anything AXModal.
2187
+ // web: at or below a web area (page content, never pressed by intent).
2188
+ let dialogWindow = role == "AXWindow" && (subrole.map { DIALOG_SUBROLES.contains($0) } ?? false)
2189
+ let modal = item.modal || role == "AXSheet" || role == "AXPopover" || dialogWindow
2190
+ || ((vals[12] as? NSNumber)?.boolValue ?? false)
2191
+ let web = item.web || role == "AXWebArea"
2192
+ if enrich && role == "AXStaticText" && texts.count < maxTexts, let s = cleanLabel(AX.string(item.el, "AXValue")) {
2193
+ texts.append(String(s.prefix(200)))
2194
+ }
2195
+ let rawActions = AX.actions(item.el)
2196
+ let interactive = secure || (role.map { INTERACTIVE_ROLES.contains($0) } ?? false)
2197
+ || rawActions.contains(where: { INTERACTIVE_ACTIONS.contains($0) })
2198
+ var myRef = item.parent
2199
+ if !interactiveOnly || interactive {
2200
+ elements.append(item.el)
2201
+ let ref = "e\(elements.count)"
2202
+ myRef = ref
2203
+ var frame: Any = NSNull()
2204
+ if let p = AX.point(vals[6]), let s = AX.size(vals[7]) { frame = rectJSON(CGRect(origin: p, size: s)) }
2205
+ let textRole = role.map { TEXT_ROLES.contains($0) || $0 == "AXSearchField" } ?? false
2206
+ var actions = rawActions.compactMap { ACTION_NAMES[$0] ?? ($0.hasPrefix("AX") ? nil : $0) }
2207
+ if textRole { actions += ["focus", "set_value"] }
2208
+ if role == "AXCheckBox" || role == "AXRadioButton" { actions.append("toggle") }
2209
+ var node: [String: Any] = [
2210
+ "ref": ref, "parent": item.parent as Any, "depth": item.depth, "role": role as Any, "subrole": subrole as Any,
2211
+ "title": title as Any, "description": desc as Any,
2212
+ "value": (secure ? nil : readValue(item.el, role: role, limit: 200)) as Any, "secure": secure,
2213
+ "enabled": (vals[4] as? NSNumber)?.boolValue as Any, "focused": (vals[5] as? NSNumber)?.boolValue as Any,
2214
+ "actions": Array(Set(actions)).sorted(), "frame": frame,
2215
+ "help": trimmed(vals[8] as? String, 200) as Any,
2216
+ "placeholder": (textRole ? trimmed(vals[9] as? String, 200) : nil) as Any,
2217
+ "identifier": trimmed(vals[10] as? String, 120) as Any,
2218
+ "group": item.group as Any, "modal": modal, "web": web,
2219
+ ]
2220
+ if enrich {
2221
+ node["titleElement"] = titleElementLabel(AX.element(vals[11])) as Any
2222
+ let rowLike = (role.map { ROW_ROLES.contains($0) } ?? false) || subrole == "AXOutlineRow"
2223
+ let unnamed = cleanLabel(title) == nil && cleanLabel(desc) == nil
2224
+ node["contentLabel"] = (rowLike && unnamed ? contentLabel(item.el, deadline: deadline) : nil) as Any
2225
+ }
2226
+ nodes.append(node)
2227
+ }
2228
+ if item.depth < depthMax && role != "AXMenuBar" {
2229
+ let kids = AX.children(item.el, limit: 500)
2230
+ if kids.count >= 500 { truncated = true }
2231
+ let group = groupForChildren(role: role, title: title, desc: desc, inherited: item.group)
2232
+ for c in kids.reversed() {
2233
+ stack.append(AXVisit(el: c, depth: item.depth + 1, parent: myRef, group: group, modal: modal, web: web))
2234
+ }
2235
+ }
2236
+ }
2237
+ }
2238
+ if nodes.isEmpty && visited <= 1 && truncated {
2239
+ throw HelperError("AX_TIMEOUT", "the application did not answer accessibility queries in time")
2240
+ }
2241
+ let sid = Snapshots.add(elements)
2242
+ var app: [String: Any] = ["pid": Int(pid), "bundleId": running.bundleIdentifier as Any, "name": running.localizedName as Any]
2243
+ var result: [String: Any] = ["snapshotId": sid, "nodes": nodes, "truncated": truncated]
2244
+ if enrich {
2245
+ app["lang"] = appLanguage(running) as Any
2246
+ result["window"] = rootWindowJSON(root, pid: pid)
2247
+ result["texts"] = texts
2248
+ }
2249
+ result["app"] = app
2250
+ return result
2251
+ }
2252
+
2253
+ // MARK: - ax_action verify (protocol 2)
2254
+
2255
+ /// What the caller's snapshot showed. Every text field is compared (absent or
2256
+ /// null means the element must have none) after verifyText: NFC, clipped to the
2257
+ /// snapshot's limit, whitespace-trimmed.
2258
+ struct VerifySpec {
2259
+ let pid: Int
2260
+ let role: String
2261
+ let subrole: String
2262
+ let title: String
2263
+ let description: String
2264
+ let help: String
2265
+ let identifier: String
2266
+ }
2267
+
2268
+ /// The target as it is right now, read immediately before the press.
2269
+ struct VerifyFacts {
2270
+ var pid: Int? = nil
2271
+ var frontmostPid: Int? = nil
2272
+ var role: String? = nil
2273
+ var subrole: String? = nil
2274
+ var title: String? = nil
2275
+ var description: String? = nil
2276
+ var help: String? = nil
2277
+ var identifier: String? = nil
2278
+ var enabled: Bool? = nil
2279
+ var secure = false
2280
+ var canPress = false
2281
+ /// Roles between the element and its window, nearest first (the window excluded).
2282
+ var ancestorRoles: [String] = []
2283
+ var windowSubrole: String? = nil
2284
+ var windowModal = false
2285
+ var focusedWindowIsSheet = false
2286
+ var inFocusedWindow = false
2287
+ var windowHasSheet = false
2288
+ }
2289
+
2290
+ /// What verifyText trims: .whitespacesAndNewlines plus U+FEFF. The same set as
2291
+ /// protocol.js (JavaScript's \s plus U+0085), and a superset of the
2292
+ /// String.prototype.trim the Neural Interface applies to the snapshot values it
2293
+ /// sends, so a label with a BOM at one end still verifies.
2294
+ let VERIFY_TRIM = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: "\u{FEFF}"))
2295
+
2296
+ /// NFC, the first `limit` Characters (the unit the snapshot clips in), trimmed.
2297
+ func verifyText(_ s: String?, _ limit: Int) -> String {
2298
+ guard let s = s else { return "" }
2299
+ return String(nfc(s).prefix(limit)).trimmingCharacters(in: VERIFY_TRIM)
2300
+ }
2301
+
2302
+ /// `verify` is only accepted with press; malformed → BAD_ARGS (protocol.js normalizeVerify).
2303
+ func parseVerify(_ a: Args, action: String) throws -> VerifySpec? {
2304
+ guard a.has("verify") else { return nil }
2305
+ guard let d = a.dict("verify") else { throw HelperError("BAD_ARGS", "verify must be an object") }
2306
+ guard action == "press" else { throw HelperError("BAD_ARGS", "verify is only allowed with press", ["action": action]) }
2307
+ guard let pid = Args.num(d["pid"]).map(toInt), pid > 0 else { throw HelperError("BAD_ARGS", "verify.pid is required") }
2308
+ guard let role = d["role"] as? String, !verifyText(role, 200).isEmpty else { throw HelperError("BAD_ARGS", "verify.role is required") }
2309
+ func text(_ key: String, _ limit: Int) throws -> String {
2310
+ guard let v = d[key], !(v is NSNull) else { return "" }
2311
+ guard let s = v as? String else { throw HelperError("BAD_ARGS", "verify.\(key) must be a string or null", ["field": key]) }
2312
+ return verifyText(s, limit)
2313
+ }
2314
+ return VerifySpec(
2315
+ pid: pid, role: verifyText(role, 200), subrole: try text("subrole", 200), title: try text("title", 200),
2316
+ description: try text("description", 200), help: try text("help", 200), identifier: try text("identifier", 120))
2317
+ }
2318
+
2319
+ /// Pure (covered by --self-test): the first check that no longer holds, or nil.
2320
+ /// Same order and field names as protocol.js compareVerify.
2321
+ func verifyCompare(_ want: VerifySpec, _ f: VerifyFacts) -> String? {
2322
+ if f.pid != want.pid { return "pid" }
2323
+ if f.frontmostPid != want.pid { return "frontmost" }
2324
+ if verifyText(f.role, 200) != want.role { return "role" }
2325
+ if verifyText(f.subrole, 200) != want.subrole { return "subrole" }
2326
+ if verifyText(f.title, 200) != want.title { return "title" }
2327
+ if verifyText(f.description, 200) != want.description { return "description" }
2328
+ if verifyText(f.help, 200) != want.help { return "help" }
2329
+ if verifyText(f.identifier, 120) != want.identifier { return "identifier" }
2330
+ if f.enabled != true { return "enabled" }
2331
+ if f.secure { return "secure" }
2332
+ if !f.canPress { return "press" }
2333
+ if f.ancestorRoles.contains("AXSheet") { return "sheet" }
2334
+ if f.ancestorRoles.contains("AXPopover") { return "popover" }
2335
+ if let s = f.windowSubrole, DIALOG_SUBROLES.contains(s) { return "dialog" }
2336
+ if f.windowModal { return "modal" }
2337
+ if f.focusedWindowIsSheet { return "sheet" }
2338
+ if !f.inFocusedWindow { return "window" }
2339
+ if f.windowHasSheet { return "sheet" }
2340
+ return nil
2341
+ }
2342
+
2343
+ /// Reads the facts verifyCompare needs: the element, its ancestors up to the
2344
+ /// window, the window, and the app's focused window.
2345
+ func verifyFacts(_ el: AXUIElement) -> VerifyFacts {
2346
+ var f = VerifyFacts()
2347
+ f.frontmostPid = NSWorkspace.shared.frontmostApplication.map { Int($0.processIdentifier) }
2348
+ guard let pid = AX.pid(el) else { return f }
2349
+ f.pid = Int(pid)
2350
+ let (err, v) = AX.multi(el, ["AXRole", "AXSubrole", "AXTitle", "AXDescription", "AXHelp", "AXIdentifier", "AXEnabled", "AXWindow"])
2351
+ guard err == .success else { return f }
2352
+ f.role = v[0] as? String
2353
+ f.subrole = v[1] as? String
2354
+ f.title = v[2] as? String
2355
+ f.description = v[3] as? String
2356
+ f.help = v[4] as? String
2357
+ f.identifier = v[5] as? String
2358
+ f.enabled = (v[6] as? NSNumber)?.boolValue
2359
+ f.secure = f.subrole == "AXSecureTextField" || f.role == "AXSecureTextField"
2360
+ f.canPress = AX.actions(el).contains("AXPress")
2361
+ var cur = AX.element(AX.copy(el, "AXParent").1)
2362
+ var hops = 0
2363
+ while let c = cur, hops < 64 {
2364
+ guard let r = AX.string(c, "AXRole"), r != "AXWindow", r != "AXApplication" else { break }
2365
+ f.ancestorRoles.append(r)
2366
+ cur = AX.element(AX.copy(c, "AXParent").1)
2367
+ hops += 1
2368
+ }
2369
+ let win = AX.element(v[7])
2370
+ if let w = win {
2371
+ let (we, wv) = AX.multi(w, ["AXSubrole", "AXModal"])
2372
+ if we == .success {
2373
+ f.windowSubrole = wv[0] as? String
2374
+ f.windowModal = (wv[1] as? NSNumber)?.boolValue ?? false
2375
+ }
2376
+ }
2377
+ let appEl = AXUIElementCreateApplication(pid)
2378
+ if let fw = AX.element(AX.copy(appEl, "AXFocusedWindow").1) {
2379
+ f.focusedWindowIsSheet = AX.string(fw, "AXRole") == "AXSheet"
2380
+ if let w = win { f.inFocusedWindow = CFEqual(fw, w) }
2381
+ f.windowHasSheet = AX.children(fw, limit: 500).contains { AX.string($0, "AXRole") == "AXSheet" }
2382
+ }
2383
+ return f
2384
+ }
2385
+
2386
+ func verifyTarget(_ el: AXUIElement, _ want: VerifySpec, probe: Probe) throws {
2387
+ if let field = verifyCompare(want, verifyFacts(el)) {
2388
+ throw HelperError("TARGET_CHANGED", "the target is no longer what the snapshot showed (\(field)); nothing was pressed",
2389
+ ["field": field, "probe": probe.json])
2390
+ }
2391
+ }
2392
+
2393
+ /// Order: preInput → lookup (REF_EXPIRED) → probeElement → matchGuard on a fresh
2394
+ /// probe (window title re-read) → password-field check → verifyTarget → the
2395
+ /// action. Nothing runs between verifyTarget and AXPerformAction.
2396
+ func cmdAxAction(_ a: Args) throws -> Any {
2397
+ try preInput()
2398
+ AX.prepare()
2399
+ guard let sid = a.string("snapshotId"), let ref = a.string("ref") else { throw HelperError("BAD_ARGS", "snapshotId and ref are required") }
2400
+ let action = a.string("action") ?? "press"
2401
+ let verify = try parseVerify(a, action: action)
2402
+ guard let el = Snapshots.element(sid, ref) else {
2403
+ throw HelperError("REF_EXPIRED", "\(sid)/\(ref) is not in the last 4 snapshots", ["snapshotId": sid, "ref": ref])
2404
+ }
2405
+ let probe = probeElement(el)
2406
+ if probe.axError == .invalidUIElement { throw HelperError("REF_EXPIRED", "\(sid)/\(ref) no longer exists", ["snapshotId": sid, "ref": ref]) }
2407
+ if probe.axError == .cannotComplete { throw axFail(probe.axError, "ax_action") }
2408
+ if let e = matchGuard(probe, Config.guardCfg, context: "ax_action") { throw e }
2409
+ if action == "set_value" && Config.guardCfg.secureField {
2410
+ if probe.secure { throw HelperError("SECURE_FIELD", "refusing to set the value of a password field", ["probe": probe.json]) }
2411
+ if !probe.readable && IsSecureEventInputEnabled() {
2412
+ throw HelperError("SECURE_FIELD", "secure input is active and the target can't be read", ["probe": probe.json])
2413
+ }
2414
+ }
2415
+ if let want = verify { try verifyTarget(el, want, probe: probe) }
2416
+ var err: AXError
2417
+ switch action {
2418
+ case "press", "toggle":
2419
+ err = AX.perform(el, "AXPress")
2420
+ case "focus":
2421
+ err = AX.setBool(el, "AXFocused", true)
2422
+ case "set_value":
2423
+ guard a.has("value") else { throw HelperError("BAD_ARGS", "value is required for set_value") }
2424
+ let v = a.raw["value"]!
2425
+ if let s = v as? String {
2426
+ err = AXUIElementSetAttributeValue(el, "AXValue" as CFString, s as CFString)
2427
+ } else if let n = v as? NSNumber {
2428
+ err = AXUIElementSetAttributeValue(el, "AXValue" as CFString, n)
2429
+ } else {
2430
+ throw HelperError("BAD_ARGS", "value must be a string, number or boolean")
2431
+ }
2432
+ case "expand", "collapse":
2433
+ let on = action == "expand"
2434
+ err = AX.setBool(el, "AXExpanded", on)
2435
+ if err != .success { err = AX.setBool(el, "AXDisclosing", on) }
2436
+ case "select":
2437
+ err = AX.setBool(el, "AXSelected", true)
2438
+ if err != .success { err = AX.perform(el, "AXPick") }
2439
+ case "scroll_into_view":
2440
+ err = AX.perform(el, "AXScrollToVisible")
2441
+ case "raise":
2442
+ if probe.role == "AXWindow" {
2443
+ err = AX.perform(el, "AXRaise")
2444
+ } else if let win = AX.element(AX.copy(el, "AXWindow").1) {
2445
+ err = AX.perform(win, "AXRaise")
2446
+ } else {
2447
+ err = .actionUnsupported
2448
+ }
2449
+ case "show_menu":
2450
+ err = AX.perform(el, "AXShowMenu")
2451
+ default:
2452
+ throw HelperError("BAD_ARGS", "unknown action '\(action)'")
2453
+ }
2454
+ if err != .success { throw axFail(err, "ax_action \(action)") }
2455
+ return ["target": probe.json]
2456
+ }
2457
+
2458
+ // MARK: - Configure, permissions, power
2459
+
2460
+ func cmdConfigure(_ a: Args) throws -> Any {
2461
+ var g = Config.guardCfg
2462
+ var m = Config.monitor
2463
+ var i = Config.input
2464
+ if let d = a.dict("guard") {
2465
+ try parseGuard(d, into: &g)
2466
+ } else if a.has("guard") {
2467
+ throw HelperError("BAD_ARGS", "guard must be an object")
2468
+ }
2469
+ if let d = a.dict("monitor") {
2470
+ if let b = Args.bool(d["armed"]) { m.armed = b }
2471
+ if let b = Args.bool(d["esc"]) { m.esc = b }
2472
+ if let b = Args.bool(d["failsafeCorner"]) { m.failsafeCorner = b }
2473
+ if let n = Args.num(d["cornerSizePt"]) { m.cornerSizePt = clampDouble(n, 1, 200) }
2474
+ }
2475
+ if let d = a.dict("input") {
2476
+ if let n = Args.num(d["typeChunk"]) { i.typeChunk = clampInt(toInt(n), 1, 20) }
2477
+ if let n = Args.num(d["typeDelayMs"]) { i.typeDelayMs = clampDouble(n, 0, 1000) }
2478
+ if let n = Args.num(d["moveSteps"]) { i.moveSteps = clampInt(toInt(n), 1, 200) }
2479
+ }
2480
+ Config.commit(g, m, i)
2481
+ Input.shared.setLatched(false)
2482
+ DispatchQueue.main.async { Monitor.ensureInstalled() }
2483
+ return [String: Any]()
2484
+ }
2485
+
2486
+ func cmdRequestPermission(_ a: Args) throws -> Any {
2487
+ switch a.string("kind") {
2488
+ case "screen":
2489
+ _ = onMain { CGRequestScreenCaptureAccess() }
2490
+ case "accessibility":
2491
+ _ = onMain { AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary) }
2492
+ default:
2493
+ throw HelperError("BAD_ARGS", "kind must be 'screen' or 'accessibility'")
2494
+ }
2495
+ return permissionsJSON()
2496
+ }
2497
+
2498
+ enum Power {
2499
+ private static let lock = NSLock()
2500
+ private static var assertion: IOPMAssertionID = 0
2501
+
2502
+ static func set(_ hold: Bool) throws {
2503
+ lock.lock()
2504
+ defer { lock.unlock() }
2505
+ if hold && assertion == 0 {
2506
+ var id: IOPMAssertionID = 0
2507
+ let r = IOPMAssertionCreateWithName(kIOPMAssertionTypePreventUserIdleDisplaySleep as CFString,
2508
+ IOPMAssertionLevel(kIOPMAssertionLevelOn), "SynaBun computer use" as CFString, &id)
2509
+ guard r == kIOReturnSuccess else { throw HelperError("INTERNAL", "could not hold the display awake (IOReturn \(r))") }
2510
+ assertion = id
2511
+ } else if !hold && assertion != 0 {
2512
+ IOPMAssertionRelease(assertion)
2513
+ assertion = 0
2514
+ }
2515
+ }
2516
+
2517
+ static func release() { try? set(false) }
2518
+ }
2519
+
2520
+ // MARK: - Global monitor (user input, emergency stop)
2521
+
2522
+ enum Monitor {
2523
+ private static var mouseMonitor: Any?
2524
+ private static var keyMonitor: Any?
2525
+ private static var wanted = false
2526
+ private static var inCorner = false
2527
+ private static var lastEmitNs: UInt64 = 0
2528
+
2529
+ /// Installed on the first `configure`, never at launch. The keyboard monitor needs
2530
+ /// Accessibility and is only added once it's granted (no prompt is ever triggered).
2531
+ static func ensureInstalled() {
2532
+ wanted = true
2533
+ if mouseMonitor == nil {
2534
+ let mask: NSEvent.EventTypeMask = [
2535
+ .mouseMoved, .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .otherMouseDown, .otherMouseUp,
2536
+ .leftMouseDragged, .rightMouseDragged, .otherMouseDragged, .scrollWheel,
2537
+ ]
2538
+ mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: mask) { handle($0) }
2539
+ }
2540
+ if keyMonitor == nil && AXIsProcessTrusted() {
2541
+ keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown]) { handle($0) }
2542
+ }
2543
+ }
2544
+
2545
+ static func permissionsChanged() { if wanted { ensureInstalled() } }
2546
+
2547
+ private static func handle(_ ev: NSEvent) {
2548
+ guard let cg = ev.cgEvent else { return }
2549
+ if cg.getIntegerValueField(.eventSourceUserData) == SYNB_STAMP { return } // ours
2550
+ let loc = cg.location
2551
+ let now = nowNs()
2552
+ let isKey = ev.type == .keyDown
2553
+ if isKey || ev.type == .leftMouseDown || ev.type == .rightMouseDown || ev.type == .otherMouseDown {
2554
+ Input.shared.noteUserInterrupt(now) // stops an in-progress `type`
2555
+ }
2556
+ let m = Config.monitor
2557
+ if m.armed {
2558
+ if m.esc && isKey && ev.keyCode == 53 && !ev.isARepeat { emergencyStop("esc") }
2559
+ if m.failsafeCorner && !isKey {
2560
+ let s = CGFloat(m.cornerSizePt)
2561
+ let inside = loc.x >= 0 && loc.y >= 0 && loc.x < s && loc.y < s
2562
+ if inside && !inCorner { emergencyStop("failsafe_corner") }
2563
+ inCorner = inside
2564
+ }
2565
+ } else {
2566
+ inCorner = false
2567
+ }
2568
+ if now &- lastEmitNs >= 250_000_000 { // ≤ 4 events per second
2569
+ lastEmitNs = now
2570
+ let kind = isKey ? "key" : (ev.type == .scrollWheel ? "scroll" : "mouse")
2571
+ emitEvent("user_input", ["kind": kind, "x": Double(loc.x), "y": Double(loc.y)])
2572
+ }
2573
+ }
2574
+
2575
+ private static func emergencyStop(_ reason: String) {
2576
+ Input.shared.setLatched(true)
2577
+ Input.shared.abort()
2578
+ emitEvent("emergency_stop", ["reason": reason])
2579
+ }
2580
+ }
2581
+
2582
+ // MARK: - Commands
2583
+
2584
+ enum Commands {
2585
+ static func execute(_ cmd: String, _ a: Args) throws -> Any {
2586
+ switch cmd {
2587
+ case "permissions": return permissionsJSON()
2588
+ case "request_permission": return try cmdRequestPermission(a)
2589
+ case "displays": return Displays.list()
2590
+ case "session_state": return sessionStateJSON()
2591
+ case "configure": return try cmdConfigure(a)
2592
+ case "screenshot": return try cmdScreenshot(a)
2593
+ case "capture_rect": return try cmdCaptureRect(a)
2594
+ case "cursor": return pointJSON(cursorLocation())
2595
+ case "move": return try cmdMove(a)
2596
+ case "click": return try cmdClick(a)
2597
+ case "mouse_down": return try cmdMouseDown(a)
2598
+ case "mouse_up": return try cmdMouseUp(a)
2599
+ case "drag": return try cmdDrag(a)
2600
+ case "scroll": return try cmdScroll(a)
2601
+ case "type": return try cmdType(a)
2602
+ case "key": return try cmdKey(a, hold: false)
2603
+ case "hold_key": return try cmdKey(a, hold: true)
2604
+ case "probe_point":
2605
+ try requireTrusted()
2606
+ let p = try a.point()
2607
+ try requireOnScreen(p)
2608
+ return try probeAt(p).json
2609
+ case "probe_focus":
2610
+ try requireTrusted()
2611
+ return try probeFocus().json
2612
+ case "apps": return cmdApps()
2613
+ case "windows": return cmdWindows(a)
2614
+ case "open_app": return try cmdOpenApp(a)
2615
+ case "focus_app": return try cmdFocusApp(a)
2616
+ case "ax_snapshot": return try cmdAxSnapshot(a)
2617
+ case "ax_action": return try cmdAxAction(a)
2618
+ case "abort":
2619
+ Input.shared.abort()
2620
+ return [String: Any]()
2621
+ case "panic":
2622
+ Input.shared.panic()
2623
+ return [String: Any]()
2624
+ case "power":
2625
+ guard let hold = a.bool("holdDisplayAwake") else { throw HelperError("BAD_ARGS", "holdDisplayAwake is required") }
2626
+ try Power.set(hold)
2627
+ return [String: Any]()
2628
+ case "shutdown": return [String: Any]()
2629
+ default: throw HelperError("UNSUPPORTED", "unknown command '\(cmd)'")
2630
+ }
2631
+ }
2632
+ }
2633
+
2634
+ enum Dispatcher {
2635
+ static let actions = DispatchQueue(label: "ai.synabun.desktop.actions", qos: .userInitiated)
2636
+ static let bypass: Set<String> = ["abort", "panic", "permissions", "session_state", "cursor", "shutdown"]
2637
+
2638
+ static func handle(_ line: String) {
2639
+ guard let data = line.data(using: .utf8),
2640
+ let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
2641
+ hlog("warn", "ignored a stdin line that is not a JSON object")
2642
+ return
2643
+ }
2644
+ let id = obj["id"]
2645
+ guard let cmd = obj["cmd"] as? String else {
2646
+ replyError(id, HelperError("BAD_ARGS", "cmd is required"))
2647
+ return
2648
+ }
2649
+ let args = Args(obj["args"] as? [String: Any] ?? [:])
2650
+ if bypass.contains(cmd) { run(id, cmd, args) } else { actions.async { run(id, cmd, args) } }
2651
+ }
2652
+
2653
+ static func run(_ id: Any?, _ cmd: String, _ args: Args) {
2654
+ autoreleasepool {
2655
+ do {
2656
+ reply(id, result: try Commands.execute(cmd, args))
2657
+ } catch let e as HelperError {
2658
+ replyError(id, e)
2659
+ } catch {
2660
+ replyError(id, HelperError("INTERNAL", String(describing: error)))
2661
+ }
2662
+ }
2663
+ if cmd == "shutdown" { Lifecycle.exit(reason: "shutdown") }
2664
+ }
2665
+ }
2666
+
2667
+ // MARK: - Lifecycle
2668
+
2669
+ enum Lifecycle {
2670
+ private static let lock = NSLock()
2671
+ private static var exiting = false
2672
+ private static var lastPerms: [String: Bool] = [:]
2673
+ private static var lastLocked: Bool? = nil
2674
+ private static var displaysWork: DispatchWorkItem?
2675
+ private static var timer: DispatchSourceTimer?
2676
+ private static var signalSources: [DispatchSourceSignal] = []
2677
+
2678
+ /// Releases what we hold (tracked state only) and the power assertion, then exits.
2679
+ static func exit(reason: String, code: Int32 = 0) {
2680
+ lock.lock()
2681
+ if exiting { lock.unlock(); return }
2682
+ exiting = true
2683
+ lock.unlock()
2684
+ Input.shared.abort()
2685
+ Power.release()
2686
+ Darwin.exit(code)
2687
+ }
2688
+
2689
+ static func emitReady() {
2690
+ let v = ProcessInfo.processInfo.operatingSystemVersion
2691
+ let hash = ProcessInfo.processInfo.environment["SYNABUN_DESKTOP_SOURCE_HASH"]
2692
+ var captureInRect = false
2693
+ if #available(macOS 15.2, *) { captureInRect = true }
2694
+ emitEvent("ready", [
2695
+ "protocol": PROTOCOL_VERSION, "version": HELPER_VERSION,
2696
+ "sourceHash": ((hash?.isEmpty ?? true) ? nil : hash) as Any,
2697
+ "pid": Int(getpid()), "arch": HELPER_ARCH, "os": "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)",
2698
+ "features": [
2699
+ "sck": true, "captureInRect": captureInRect, "monitor": "nsevent",
2700
+ "axVerify": true, "guardPrefixes": true, "axEnrich": true,
2701
+ ],
2702
+ ])
2703
+ }
2704
+
2705
+ static func setLocked(_ locked: Bool) { // main thread
2706
+ if lastLocked == locked { return }
2707
+ lastLocked = locked
2708
+ emitEvent("screen_lock", ["locked": locked])
2709
+ }
2710
+
2711
+ static func installObservers() {
2712
+ for sig in [SIGTERM, SIGHUP] {
2713
+ signal(sig, SIG_IGN)
2714
+ let src = DispatchSource.makeSignalSource(signal: sig, queue: .global())
2715
+ src.setEventHandler { Lifecycle.exit(reason: "signal \(sig)") }
2716
+ src.resume()
2717
+ signalSources.append(src)
2718
+ }
2719
+
2720
+ let dnc = DistributedNotificationCenter.default()
2721
+ dnc.addObserver(forName: Notification.Name("com.apple.screenIsLocked"), object: nil, queue: .main) { _ in setLocked(true) }
2722
+ dnc.addObserver(forName: Notification.Name("com.apple.screenIsUnlocked"), object: nil, queue: .main) { _ in setLocked(false) }
2723
+
2724
+ CGDisplayRegisterReconfigurationCallback({ _, flags, _ in
2725
+ if flags.contains(.beginConfigurationFlag) { return }
2726
+ DispatchQueue.main.async { Lifecycle.displaysChanged() }
2727
+ }, nil)
2728
+
2729
+ lastPerms = permissionFlags()
2730
+ lastLocked = sessionInfo().locked
2731
+ let t = DispatchSource.makeTimerSource(queue: .main)
2732
+ t.schedule(deadline: .now() + 2, repeating: 2)
2733
+ t.setEventHandler {
2734
+ if getppid() == 1 { Lifecycle.exit(reason: "parent exited") }
2735
+ let perms = permissionFlags()
2736
+ if perms != lastPerms {
2737
+ lastPerms = perms
2738
+ emitEvent("permissions_changed", ["permissions": permissionsJSON()])
2739
+ Monitor.permissionsChanged()
2740
+ }
2741
+ setLocked(sessionInfo().locked)
2742
+ }
2743
+ t.resume()
2744
+ timer = t
2745
+ }
2746
+
2747
+ static func displaysChanged() { // main thread, debounced
2748
+ displaysWork?.cancel()
2749
+ let work = DispatchWorkItem { emitEvent("displays_changed", ["displays": Displays.list()]) }
2750
+ displaysWork = work
2751
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
2752
+ }
2753
+
2754
+ static func startReader() {
2755
+ let t = Thread {
2756
+ while let line = readLine(strippingNewline: true) {
2757
+ if line.isEmpty { continue }
2758
+ autoreleasepool { Dispatcher.handle(line) }
2759
+ }
2760
+ Lifecycle.exit(reason: "stdin closed")
2761
+ }
2762
+ t.name = "synabun-desktop.stdin"
2763
+ t.stackSize = 1 << 20
2764
+ t.start()
2765
+ }
2766
+ }
2767
+
2768
+ // MARK: - Self-test (no screen access)
2769
+
2770
+ enum SelfTest {
2771
+ static func run() -> (Bool, [String: Any]) {
2772
+ var results: [[String: Any]] = []
2773
+ var allOk = true
2774
+ func check(_ name: String, _ ok: Bool, _ detail: String = "") {
2775
+ if !ok { allOk = false }
2776
+ results.append(["name": name, "ok": ok, "detail": detail])
2777
+ }
2778
+ func combo(_ s: String) -> KeyCombo? { try? parseCombo(s, layout: nil) }
2779
+ func comboCheck(_ s: String, _ mods: [Mod], _ code: CGKeyCode?, printable: Bool? = nil) {
2780
+ guard let c = combo(s) else { return check("combo \(s)", false, "did not parse") }
2781
+ var ok = Set(c.mods) == Set(mods) && c.mods.count == mods.count && c.keyCode == code
2782
+ if let p = printable { ok = ok && c.needsSecureCheck == p }
2783
+ check("combo \(s)", ok, "mods=\(c.mods.map { $0.rawValue }) key=\(c.keyCode.map { String($0) } ?? "nil") secure=\(c.needsSecureCheck)")
2784
+ }
2785
+
2786
+ // key combos / keymap (ANSI fallback — independent of the machine's layout)
2787
+ comboCheck("cmd+shift+t", [.cmd, .shift], 0x11, printable: false)
2788
+ comboCheck("cmd+T", [.cmd, .shift], 0x11)
2789
+ comboCheck("Return", [], 0x24, printable: false)
2790
+ comboCheck("ctrl+alt+Delete", [.ctrl, .alt], 0x75)
2791
+ comboCheck("super+space", [.cmd], 0x31, printable: false)
2792
+ comboCheck("space", [], 0x31, printable: true)
2793
+ comboCheck("shift+a", [.shift], 0x00, printable: true)
2794
+ comboCheck("cmd+v", [.cmd], 0x09, printable: true)
2795
+ comboCheck("cmd+c", [.cmd], 0x08, printable: false)
2796
+ comboCheck("cmd++", [.cmd, .shift], 0x18)
2797
+ comboCheck("Page_Down", [], 0x79)
2798
+ comboCheck("BackSpace", [], 0x33)
2799
+ comboCheck("Esc", [], 0x35)
2800
+ comboCheck("F12", [], 0x6F)
2801
+ comboCheck("F20", [], 0x5A)
2802
+ comboCheck("command+option+Escape", [.cmd, .alt], 0x35)
2803
+ comboCheck("shift", [.shift], nil, printable: false)
2804
+ comboCheck("ctrl+bracketleft", [.ctrl], 0x21)
2805
+ comboCheck("question", [.shift], 0x2C, printable: true)
2806
+ for bad in ["", "cmd+", "cmd+nope", "a+b", "hyper+x", "++x"] {
2807
+ check("combo rejects '\(bad)'", combo(bad) == nil)
2808
+ }
2809
+ let layout: LayoutMap = ["a": (0x0C, 0), "A": (0x0C, 2), "q": (0x00, 0)] // AZERTY-ish
2810
+ let fr = try? parseCombo("cmd+a", layout: layout)
2811
+ check("combo follows the layout map", fr?.keyCode == 0x0C, "key=\(fr?.keyCode.map { String($0) } ?? "nil")")
2812
+
2813
+ // fit math
2814
+ let f1 = fitSize(width: 1470, height: 956, fitW: 1280, fitH: 800, scale: 2)
2815
+ check("fit 1470x956@2 into 1280x800", f1.w == 1230 && f1.h == 800, "\(f1.w)x\(f1.h)")
2816
+ let f2 = fitSize(width: 1470, height: 956, fitW: 4000, fitH: 4000, scale: 2)
2817
+ check("fit never exceeds native pixels", f2.w == 2940 && f2.h == 1912, "\(f2.w)x\(f2.h)")
2818
+ let f3 = fitSize(width: 1920, height: 1080, fitW: 1024, fitH: 768, scale: 1)
2819
+ check("fit 1920x1080@1 into 1024x768", f3.w == 1024 && f3.h == 576, "\(f3.w)x\(f3.h)")
2820
+ let f4 = fitSize(width: 300, height: 200, fitW: 1568, fitH: 1568, scale: 2)
2821
+ check("fit region upscales only to backing scale", f4.w == 600 && f4.h == 400, "\(f4.w)x\(f4.h)")
2822
+
2823
+ // scroll sign conversion
2824
+ let s1 = scrollWheelDeltas(dx: 0, dy: 3)
2825
+ check("scroll dy>0 (down) → wheel1 < 0", s1.wheel1 == -3 && s1.wheel2 == 0)
2826
+ let s2 = scrollWheelDeltas(dx: -2, dy: -1)
2827
+ check("scroll dx<0 (left) → wheel2 > 0, dy<0 (up) → wheel1 > 0", s2.wheel1 == 1 && s2.wheel2 == 2)
2828
+ let s3 = scrollWheelDeltas(dx: Int.min, dy: Int.max)
2829
+ check("scroll deltas never trap", s3.wheel1 == -1_000_000 && s3.wheel2 == 1_000_000)
2830
+ check("number → int never traps", toInt(1e300) == 1_000_000_000_000_000 && toInt(-.infinity) == 0 && toInt(.nan) == 0 && toInt(2.5) == 3)
2831
+
2832
+ // text segmentation
2833
+ let segs = segmentText("ab\ncd\te\r\nf", chunk: 20)
2834
+ let shape = segs.map { seg -> String in
2835
+ switch seg { case .text(let s): return "t:\(s)"; case .key(let c, _): return "k:\(c)" }
2836
+ }
2837
+ check("segment newlines/tabs", shape == ["t:ab", "k:36", "t:cd", "k:48", "t:e", "k:36", "t:f"], shape.joined(separator: ","))
2838
+ check("segment utf16 accounting", segs.reduce(0) { $0 + $1.utf16Count } == "ab\ncd\te\r\nf".utf16.count)
2839
+ let emoji = String(repeating: "👩‍👩‍👧", count: 4) // 8 UTF-16 units each
2840
+ let esegs = segmentText(emoji, chunk: 20)
2841
+ let noSplit = esegs.allSatisfy { seg in
2842
+ if case .text(let s) = seg { return s.utf16.count <= 20 && s.count >= 1 } else { return false }
2843
+ }
2844
+ check("segment keeps grapheme clusters whole", noSplit && esegs.count == 2, "\(esegs.count) chunks")
2845
+ check("segment chunk size", segmentText(String(repeating: "x", count: 45), chunk: 20).count == 3)
2846
+
2847
+ // guard matching
2848
+ var g = GuardConfig()
2849
+ g.blocked = [
2850
+ BlockRule(id: "pw", bundleIds: ["com.1password.1password"], nameRe: nil, windowTitleRe: nil, reason: "password manager"),
2851
+ BlockRule(id: "bank", bundleIds: [], nameRe: nil, windowTitleRe: try? NSRegularExpression(pattern: "chase|wells fargo", options: [.caseInsensitive]), reason: "banking"),
2852
+ BlockRule(id: "term", bundleIds: [], nameRe: try? NSRegularExpression(pattern: "^terminal$", options: [.caseInsensitive]), windowTitleRe: nil, reason: "shell"),
2853
+ ]
2854
+ g.protectedWindows = [ProtectRule(index: 0, bundleIds: ["com.apple.Safari"], titleRe: try! NSRegularExpression(pattern: "keychain", options: [.caseInsensitive]), reason: "secrets")]
2855
+ var p = Probe()
2856
+ p.bundleId = "COM.1password.1PASSWORD"
2857
+ check("guard blocks by bundle id (case-insensitive)", matchGuard(p, g, context: "t")?.code == "BLOCKED_APP")
2858
+ p = Probe(); p.bundleId = "com.google.Chrome"; p.app = "Google Chrome"; p.windowTitle = "Chase Online"
2859
+ check("guard blocks by window title", matchGuard(p, g, context: "t")?.code == "BLOCKED_APP")
2860
+ p = Probe(); p.app = "Terminal"
2861
+ check("guard blocks by app name", matchGuard(p, g, context: "t")?.code == "BLOCKED_APP")
2862
+ p = Probe(); p.bundleId = "com.apple.Safari"; p.app = "Safari"; p.windowTitle = "Keychain Access Help"
2863
+ check("guard protects matching windows", matchGuard(p, g, context: "t")?.code == "PROTECTED_WINDOW")
2864
+ p = Probe(); p.bundleId = "com.apple.TextEdit"; p.app = "TextEdit"; p.windowTitle = "Keychain notes"
2865
+ check("guard protected rule is scoped to its bundle ids", matchGuard(p, g, context: "t") == nil)
2866
+ p = Probe(); p.bundleId = "com.apple.Safari"; p.app = "Safari"
2867
+ check("guard ignores unknown titles", matchGuard(p, g, context: "t") == nil)
2868
+ check("guard rejects invalid regex", (try? compileRegex("(", field: "x")) == nil)
2869
+ check("guard treats empty regex as absent", ((try? compileRegex("", field: "x")) ?? nil) == nil)
2870
+
2871
+ // protocol 2 guards: bundle-id prefixes, app names, NFC
2872
+ func re(_ pattern: String) -> NSRegularExpression? { (try? compileRegex(pattern, field: "t")) ?? nil }
2873
+ func matchedBy(_ e: HelperError?) -> String? { (e?.details?["rule"] as? [String: Any])?["matched"] as? String }
2874
+ let webAppId = "com.apple.Safari.WebApp.7EF0F3F3-27FA-4C9A-9D7C-74020761B996"
2875
+ var gp = GuardConfig()
2876
+ gp.blocked = [BlockRule(id: "webapps", bundleIds: [], bundlePrefixes: ["com.apple.Safari.WebApp."], nameRe: nil, windowTitleRe: nil, reason: "web apps")]
2877
+ p = Probe(); p.bundleId = webAppId; p.app = "SynaBun"
2878
+ check("guard blocks by bundle prefix", matchedBy(matchGuard(p, gp, context: "t")) == "bundlePrefix")
2879
+ p = Probe(); p.bundleId = "COM.APPLE.SAFARI.WEBAPP.X1"
2880
+ check("bundle prefixes compare case-insensitively", matchGuard(p, gp, context: "t")?.code == "BLOCKED_APP")
2881
+ p = Probe(); p.bundleId = "com.apple.Safari"; p.app = "Safari"
2882
+ check("a prefix never matches the bare browser id", matchGuard(p, gp, context: "t") == nil)
2883
+ p = Probe(); p.bundleId = "com.apple.Safari.WebApp"
2884
+ check("a prefix needs its trailing dot", matchGuard(p, gp, context: "t") == nil)
2885
+
2886
+ var gw = GuardConfig()
2887
+ gw.protectedWindows = [ProtectRule(
2888
+ index: 0, id: "synabun-ui", bundleIds: ["com.apple.Safari"], bundlePrefixes: ["com.apple.Safari.WebApp."],
2889
+ titleRe: re("Neural Memory Interface|SynaBun|SynApp|Neural Interface"),
2890
+ appNameRe: re("^(SynaBun|SynApp|Neural (Memory )?Interface)\\b"), reason: "SynaBun")]
2891
+ p = Probe(); p.bundleId = webAppId; p.app = "SynaBun"; p.windowTitle = "Restarting..."
2892
+ let byName = matchGuard(p, gw, context: "t")
2893
+ check("web app protected by its app name, whatever the title", byName?.code == "PROTECTED_WINDOW" && matchedBy(byName) == "appName")
2894
+ p.windowTitle = nil
2895
+ check("web app protected by its app name with no title", matchGuard(p, gw, context: "t")?.code == "PROTECTED_WINDOW")
2896
+ p = Probe(); p.bundleId = "com.apple.Safari.WebApp.ABC"; p.app = "Docs"; p.windowTitle = "SynaBun"
2897
+ check("web app protected by its title", matchedBy(matchGuard(p, gw, context: "t")) == "title")
2898
+ p = Probe(); p.bundleId = "com.apple.Safari"; p.app = "Safari"; p.windowTitle = "Restarting..."
2899
+ check("Safari itself is not protected by the app-name rule", matchGuard(p, gw, context: "t") == nil)
2900
+ p = Probe(); p.bundleId = "com.apple.TextEdit"; p.app = "SynaBun"; p.windowTitle = "SynaBun notes"
2901
+ check("appNameRe is scoped like titleRe", matchGuard(p, gw, context: "t") == nil)
2902
+
2903
+ var gs = GuardConfig()
2904
+ gs.protectedWindows = [ProtectRule(index: 0, bundleIds: ["com.apple.systempreferences", "com.apple.Settings"],
2905
+ titleRe: re("^(?:Câmera|Fotos)$|Privacidade e Segurança|Acesso Total ao Disco"), reason: "settings")]
2906
+ p = Probe(); p.bundleId = "com.apple.systempreferences"; p.app = "Ajustes do Sistema"; p.windowTitle = "Privacidade e Seguranc\u{0327}a"
2907
+ check("protected by a pt title in NFD", matchGuard(p, gs, context: "t")?.code == "PROTECTED_WINDOW")
2908
+ p.windowTitle = "Ca\u{0302}mera"
2909
+ check("protected by an anchored pt name in NFD", matchGuard(p, gs, context: "t")?.code == "PROTECTED_WINDOW")
2910
+ p.windowTitle = "Câmera lenta"
2911
+ check("anchored names match whole titles only", matchGuard(p, gs, context: "t") == nil)
2912
+ p.windowTitle = "Aparência"
2913
+ check("an unprotected pt pane passes", matchGuard(p, gs, context: "t") == nil)
2914
+ var gd = GuardConfig()
2915
+ gd.protectedWindows = [ProtectRule(index: 0, bundleIds: [], titleRe: re("Seguranc\u{0327}a"), reason: "")]
2916
+ p = Probe(); p.windowTitle = "Privacidade e Segurança"
2917
+ check("an NFD pattern matches an NFC title", matchGuard(p, gd, context: "t")?.code == "PROTECTED_WINDOW")
2918
+
2919
+ var pg = GuardConfig()
2920
+ let parsed = (try? parseGuard([
2921
+ "blockedApps": [["id": "w", "bundlePrefixes": ["com.apple.Safari.WebApp."]]],
2922
+ "protectedWindows": [["id": "ui", "appNameRe": "^SynaBun\\b", "bundlePrefixes": ["com.google.Chrome.app."]]],
2923
+ ], into: &pg)) != nil
2924
+ check("parseGuard reads bundlePrefixes and appNameRe", parsed && pg.blocked.first?.bundlePrefixes == ["com.apple.Safari.WebApp."]
2925
+ && pg.protectedWindows.first?.appNameRe != nil && pg.protectedWindows.first?.titleRe == nil && pg.protectedWindows.first?.id == "ui")
2926
+ for bad in ["com.apple.Safari", "com.", ".com.apple.", "com..apple.", "com apple.", ""] {
2927
+ var gb = GuardConfig()
2928
+ check("parseGuard rejects the prefix '\(bad)'", (try? parseGuard(["blockedApps": [["id": "x", "bundlePrefixes": [bad]]]], into: &gb)) == nil)
2929
+ }
2930
+ var gn = GuardConfig()
2931
+ check("parseGuard needs titleRe or appNameRe", (try? parseGuard(["protectedWindows": [["bundleIds": ["com.x.y"]]]], into: &gn)) == nil)
2932
+
2933
+ // group labels (never a window title)
2934
+ check("group of a labelled container", groupForChildren(role: "AXOutline", title: nil, desc: " Barra\nlateral ", inherited: "toolbar") == "outline Barra lateral")
2935
+ check("group of a bare toolbar", groupForChildren(role: "AXToolbar", title: nil, desc: nil, inherited: nil) == "toolbar")
2936
+ check("an unlabelled group inherits", groupForChildren(role: "AXGroup", title: " ", desc: nil, inherited: "sheet") == "sheet")
2937
+ check("a window is never a group", groupForChildren(role: "AXWindow", title: "Secret.txt", desc: nil, inherited: nil) == nil)
2938
+ check("groups are clipped to 60", groupForChildren(role: "AXGroup", title: String(repeating: "x", count: 100), desc: nil, inherited: nil)?.count == 60)
2939
+
2940
+ // ax_action verify: the decision, without AX
2941
+ let want = VerifySpec(pid: 42, role: "AXButton", subrole: "", title: "Voltar", description: "", help: "Go back", identifier: "back")
2942
+ var facts = VerifyFacts()
2943
+ facts.pid = 42; facts.frontmostPid = 42; facts.role = "AXButton"; facts.title = " Voltar "; facts.help = "Go back"
2944
+ facts.identifier = "back"; facts.enabled = true; facts.canPress = true; facts.inFocusedWindow = true
2945
+ facts.windowSubrole = "AXStandardWindow"; facts.ancestorRoles = ["AXGroup", "AXToolbar"]
2946
+ check("verify passes an unchanged target", verifyCompare(want, facts) == nil, verifyCompare(want, facts) ?? "")
2947
+ func changed(_ label: String, _ field: String, _ mutate: (inout VerifyFacts) -> Void) {
2948
+ var f = facts
2949
+ mutate(&f)
2950
+ let got = verifyCompare(want, f)
2951
+ check("verify: \(label) → \(field)", got == field, got ?? "nil")
2952
+ }
2953
+ changed("other process", "pid") { $0.pid = 43 }
2954
+ changed("app in the background", "frontmost") { $0.frontmostPid = 7 }
2955
+ changed("role", "role") { $0.role = "AXLink" }
2956
+ changed("subrole", "subrole") { $0.subrole = "AXCloseButton" }
2957
+ changed("relabelled", "title") { $0.title = "Apagar" }
2958
+ changed("description added", "description") { $0.description = "trash" }
2959
+ changed("help removed", "help") { $0.help = nil }
2960
+ changed("identifier", "identifier") { $0.identifier = "delete" }
2961
+ changed("disabled", "enabled") { $0.enabled = false }
2962
+ changed("enabled unknown", "enabled") { $0.enabled = nil }
2963
+ changed("secure", "secure") { $0.secure = true }
2964
+ changed("no AXPress", "press") { $0.canPress = false }
2965
+ changed("inside a sheet", "sheet") { $0.ancestorRoles = ["AXGroup", "AXSheet"] }
2966
+ changed("inside a popover", "popover") { $0.ancestorRoles = ["AXPopover"] }
2967
+ changed("dialog window", "dialog") { $0.windowSubrole = "AXSystemDialog" }
2968
+ changed("modal window", "modal") { $0.windowModal = true }
2969
+ changed("focused window is a sheet", "sheet") { $0.focusedWindowIsSheet = true }
2970
+ changed("not the focused window", "window") { $0.inFocusedWindow = false }
2971
+ changed("a sheet opened over the window", "sheet") { $0.windowHasSheet = true }
2972
+ var nfdFacts = facts
2973
+ nfdFacts.title = "Seguranc\u{0327}a"
2974
+ let nfcWant = VerifySpec(pid: 42, role: "AXButton", subrole: "", title: "Segurança", description: "", help: "Go back", identifier: "back")
2975
+ check("verify compares text in NFC", verifyCompare(nfcWant, nfdFacts) == nil)
2976
+ let seen = String(repeating: "a", count: 200)
2977
+ var longFacts = facts
2978
+ longFacts.title = seen + " and more than the snapshot kept"
2979
+ let longWant = VerifySpec(pid: 42, role: "AXButton", subrole: "", title: seen, description: "", help: "Go back", identifier: "back")
2980
+ check("verify ignores text past the snapshot's limit", verifyCompare(longWant, longFacts) == nil)
2981
+ // The whole chain: the snapshot clips (trimmed), the Neural Interface trims
2982
+ // (String.prototype.trim) and sends it as verify, the helper re-reads the live label.
2983
+ func roundTrip(live: String, sent: String) -> String? {
2984
+ guard let want = (try? parseVerify(Args(["verify": ["pid": 42, "role": "AXButton", "title": sent, "help": "Go back", "identifier": "back"]]), action: "press")) ?? nil else { return "parse" }
2985
+ var f = facts
2986
+ f.title = live
2987
+ return verifyCompare(want, f)
2988
+ }
2989
+ let longLabel = String(repeating: "Mover para o Lixo ", count: 15)
2990
+ check("a label longer than 200 verifies against its snapshot copy",
2991
+ roundTrip(live: longLabel, sent: trimmed(longLabel, 200)!.trimmingCharacters(in: .whitespaces)) == nil)
2992
+ let nfdLong = String(repeating: "e\u{0301}", count: 250)
2993
+ check("a decomposed label longer than 200 verifies against its snapshot copy", roundTrip(live: nfdLong, sent: trimmed(nfdLong, 200)!) == nil)
2994
+ check("verify trims a BOM the way the Neural Interface does", roundTrip(live: "\u{FEFF}Voltar\u{FEFF}", sent: "Voltar") == nil)
2995
+ check("verify still sees a changed long label", roundTrip(live: "x" + longLabel, sent: trimmed(longLabel, 200)!) == "title")
2996
+ func verifyArgsError(_ raw: [String: Any], _ action: String) -> String? {
2997
+ do { _ = try parseVerify(Args(raw), action: action); return nil } catch let e as HelperError { return e.code } catch { return "?" }
2998
+ }
2999
+ let okVerify: [String: Any] = ["pid": 42, "role": "AXButton", "title": "Voltar", "subrole": NSNull()]
3000
+ check("verify is accepted with press", verifyArgsError(["verify": okVerify], "press") == nil)
3001
+ check("verify is refused with toggle", verifyArgsError(["verify": okVerify], "toggle") == "BAD_ARGS")
3002
+ check("verify must be an object", verifyArgsError(["verify": "yes"], "press") == "BAD_ARGS")
3003
+ check("verify needs a pid", verifyArgsError(["verify": ["role": "AXButton"]], "press") == "BAD_ARGS")
3004
+ check("verify needs a role", verifyArgsError(["verify": ["pid": 42]], "press") == "BAD_ARGS")
3005
+ check("verify text fields are strings or null", verifyArgsError(["verify": ["pid": 42, "role": "AXButton", "title": 5]], "press") == "BAD_ARGS")
3006
+ let spec = (try? parseVerify(Args(["verify": okVerify]), action: "press")) ?? nil
3007
+ check("verify normalizes what it was given", spec?.title == "Voltar" && spec?.subrole == "" && spec?.help == "" && spec?.pid == 42)
3008
+
3009
+ // JSON round-trip
3010
+ let original: [String: Any] = ["id": 17, "ok": true, "s": "line1\nline2 \"q\" \\ é 👩‍👩‍👧 \u{2028}", "n": 1.5, "a": [1, "x", NSNull()], "nan": Double.nan]
3011
+ if let data = encodeJSON(original), let back = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
3012
+ let str = String(data: data, encoding: .utf8) ?? ""
3013
+ let ok = (back["s"] as? String) == (original["s"] as? String) && (back["id"] as? NSNumber)?.intValue == 17
3014
+ && (back["ok"] as? Bool) == true && back["nan"] is NSNull && !str.contains("\n")
3015
+ check("json round-trip", ok, str)
3016
+ } else {
3017
+ check("json round-trip", false, "encode failed")
3018
+ }
3019
+ check("json drops non-finite numbers", encodeJSON(["x": Double.infinity]) != nil)
3020
+
3021
+ return (allOk, ["ok": allOk, "version": HELPER_VERSION, "protocol": PROTOCOL_VERSION, "tests": results])
3022
+ }
3023
+ }
3024
+
3025
+ // MARK: - Main
3026
+
3027
+ func printLine(_ obj: [String: Any]) {
3028
+ if let data = encodeJSON(obj), let s = String(data: data, encoding: .utf8) { print(s) }
3029
+ }
3030
+
3031
+ let arguments = CommandLine.arguments
3032
+ if arguments.contains("--version") {
3033
+ printLine(["version": HELPER_VERSION, "protocol": PROTOCOL_VERSION])
3034
+ exit(0)
3035
+ }
3036
+ if arguments.contains("--self-test") {
3037
+ let (ok, report) = SelfTest.run()
3038
+ printLine(report)
3039
+ exit(ok ? 0 : 1)
3040
+ }
3041
+
3042
+ signal(SIGPIPE, SIG_IGN) // a dead parent surfaces as a write error, not a kill
3043
+ signal(SIGINT, SIG_IGN) // Ctrl+C in the launching terminal: the server owns the shutdown
3044
+
3045
+ let application = NSApplication.shared
3046
+ application.setActivationPolicy(.accessory)
3047
+ // No App Nap: timers and the event monitor must stay prompt while idle.
3048
+ let activity = ProcessInfo.processInfo.beginActivity(options: [.userInitiatedAllowingIdleSystemSleep, .latencyCritical],
3049
+ reason: "SynaBun desktop control")
3050
+ Lifecycle.installObservers()
3051
+ DispatchQueue.main.async {
3052
+ Lifecycle.emitReady()
3053
+ Lifecycle.startReader()
3054
+ }
3055
+ application.run()
3056
+ withExtendedLifetime(activity) {}