agentix-cli 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -118
- package/apps/mac-helper/Sources/AgentXHelper/AXTree.swift +159 -0
- package/apps/mac-helper/Sources/AgentXHelper/Brand.swift +63 -0
- package/apps/mac-helper/Sources/AgentXHelper/Focus.swift +97 -0
- package/apps/mac-helper/Sources/AgentXHelper/HUD.swift +179 -0
- package/apps/mac-helper/Sources/AgentXHelper/OCR.swift +117 -0
- package/apps/mac-helper/Sources/AgentXHelper/Pointer.swift +309 -0
- package/apps/mac-helper/Sources/AgentXHelper/Screens.swift +67 -0
- package/apps/mac-helper/Sources/AgentXHelper/Typer.swift +185 -0
- package/apps/mac-helper/Sources/AgentXHelper/Vision.swift +201 -0
- package/apps/mac-helper/Sources/AgentXHelper/main.swift +265 -0
- package/apps/mac-helper/build.sh +39 -0
- package/apps/mac-voice/Resources/Info.plist +18 -0
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +108 -0
- package/apps/mac-voice/Sources/AgentXVoice/Brand.swift +123 -0
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +68 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hold.swift +74 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +76 -0
- package/apps/mac-voice/Sources/AgentXVoice/Markdown.swift +140 -0
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +275 -0
- package/apps/mac-voice/Sources/AgentXVoice/Progress.swift +147 -0
- package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +139 -0
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +162 -0
- package/apps/mac-voice/Sources/AgentXVoice/SmartPaste.swift +72 -0
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +192 -0
- package/apps/mac-voice/Sources/AgentXVoice/StepLabel.swift +159 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +380 -0
- package/apps/mac-voice/build.sh +32 -0
- package/dist/{App-BPNHIGUX.js → App-CKPH3HHF.js} +2 -2
- package/dist/{ChatApp-6XSSZB4U.js → ChatApp-MX7IG4K2.js} +2 -2
- package/dist/agent-2U574KBZ.js +2 -0
- package/dist/article-fields-L4D2BOGY.js +2 -0
- package/dist/article-quality-44ONVGLN.js +3 -0
- package/dist/article-quality-44ONVGLN.js.map +1 -0
- package/dist/{audit-JU3HWISB.js → audit-4245CX6P.js} +2 -2
- package/dist/backfill-MTD2B62I.js +6 -0
- package/dist/backfill-MTD2B62I.js.map +1 -0
- package/dist/{board-dashboard-M3372FLO.js → board-dashboard-QR7CVTKA.js} +1411 -453
- package/dist/board-dashboard-QR7CVTKA.js.map +1 -0
- package/dist/{builtin-FRKAQMFS.js → builtin-B67DMJ3M.js} +2 -2
- package/dist/calibration-ILS5OI44.js +2 -0
- package/dist/{chunk-V3U4OF7L.js → chunk-3LYM22IV.js} +46 -48
- package/dist/chunk-3LYM22IV.js.map +1 -0
- package/dist/chunk-3SCPRYJB.js +5 -0
- package/dist/chunk-3SCPRYJB.js.map +1 -0
- package/dist/chunk-4BR7D2OU.js +36 -0
- package/dist/chunk-4BR7D2OU.js.map +1 -0
- package/dist/{chunk-P4VWIVKN.js → chunk-52ZPELD2.js} +403 -29
- package/dist/chunk-52ZPELD2.js.map +1 -0
- package/dist/chunk-5IA2CYHN.js +5 -0
- package/dist/{chunk-O6YPEOQF.js.map → chunk-5IA2CYHN.js.map} +1 -1
- package/dist/chunk-64CAIB3C.js +9 -0
- package/dist/chunk-64CAIB3C.js.map +1 -0
- package/dist/{chunk-5U35WAZF.js → chunk-77OV7346.js} +6 -6
- package/dist/chunk-77OV7346.js.map +1 -0
- package/dist/chunk-AJ5RMU7X.js +2 -0
- package/dist/chunk-AJ5RMU7X.js.map +1 -0
- package/dist/chunk-AJONZWLT.js +2 -0
- package/dist/chunk-AJONZWLT.js.map +1 -0
- package/dist/chunk-BH3GTCZ6.js +2 -0
- package/dist/chunk-BH3GTCZ6.js.map +1 -0
- package/dist/chunk-E3UJHQGN.js +1189 -0
- package/dist/chunk-E3UJHQGN.js.map +1 -0
- package/dist/chunk-EVWEG3VD.js +3 -0
- package/dist/chunk-EVWEG3VD.js.map +1 -0
- package/dist/chunk-F4HZTZHW.js +2 -0
- package/dist/chunk-F4HZTZHW.js.map +1 -0
- package/dist/chunk-F6CNF7V3.js +25 -0
- package/dist/chunk-F6CNF7V3.js.map +1 -0
- package/dist/chunk-FIOB3MEX.js +2 -0
- package/dist/{chunk-H5D7ATXS.js → chunk-FKBGJYWQ.js} +5 -5
- package/dist/chunk-FKBGJYWQ.js.map +1 -0
- package/dist/{chunk-BVGJSPW4.js → chunk-G2HMRDUQ.js} +7 -7
- package/dist/chunk-G2HMRDUQ.js.map +1 -0
- package/dist/chunk-H2QQU2XV.js +109 -0
- package/dist/chunk-H2QQU2XV.js.map +1 -0
- package/dist/{chunk-LDHPMJ7M.js → chunk-IWMW5432.js} +3 -3
- package/dist/chunk-IWMW5432.js.map +1 -0
- package/dist/chunk-K33GIBSE.js +2 -0
- package/dist/chunk-K33GIBSE.js.map +1 -0
- package/dist/chunk-L2WSPY6S.js +2 -0
- package/dist/chunk-L2WSPY6S.js.map +1 -0
- package/dist/chunk-LCIREYTD.js +54 -0
- package/dist/chunk-LCIREYTD.js.map +1 -0
- package/dist/chunk-MHIMGM4G.js +126 -0
- package/dist/chunk-MHIMGM4G.js.map +1 -0
- package/dist/chunk-P5ZBBEPL.js +13 -0
- package/dist/chunk-P5ZBBEPL.js.map +1 -0
- package/dist/{chunk-JTJIPNXY.js → chunk-PNWNVOEZ.js} +1 -1
- package/dist/chunk-PNWNVOEZ.js.map +1 -0
- package/dist/chunk-QHTQPBHU.js +45 -0
- package/dist/chunk-QHTQPBHU.js.map +1 -0
- package/dist/chunk-S2MNL5V6.js +2 -0
- package/dist/chunk-S2MNL5V6.js.map +1 -0
- package/dist/chunk-SCTIUFL4.js +5 -0
- package/dist/{chunk-K2CAPYES.js.map → chunk-SCTIUFL4.js.map} +1 -1
- package/dist/chunk-ULLAE6LE.js +157 -0
- package/dist/chunk-ULLAE6LE.js.map +1 -0
- package/dist/{chunk-DJEZ2HVU.js → chunk-VDMYBPYB.js} +1 -1
- package/dist/chunk-VDMYBPYB.js.map +1 -0
- package/dist/chunk-VIH7ARPF.js +547 -0
- package/dist/chunk-VIH7ARPF.js.map +1 -0
- package/dist/chunk-W5C2GDNC.js +32 -0
- package/dist/{chunk-JP5IEXR7.js.map → chunk-W5C2GDNC.js.map} +1 -1
- package/dist/chunk-WPMX3VGI.js +3 -0
- package/dist/chunk-WPMX3VGI.js.map +1 -0
- package/dist/chunk-YLBERT7N.js +24 -0
- package/dist/chunk-YLBERT7N.js.map +1 -0
- package/dist/chunk-YLGIZRHV.js +114 -0
- package/dist/chunk-YLGIZRHV.js.map +1 -0
- package/dist/chunk-ZMYGXHPT.js +121 -0
- package/dist/chunk-ZMYGXHPT.js.map +1 -0
- package/dist/chunk-ZTEU5JVQ.js +2 -0
- package/dist/chunk-ZTEU5JVQ.js.map +1 -0
- package/dist/{chunk-2WVX3LEC.js → chunk-ZVP6GB4G.js} +1 -1
- package/dist/chunk-ZVP6GB4G.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client-ZLSXGPTV.js +2 -0
- package/dist/{compaction-NWEMOSHA.js → compaction-FD4R23HZ.js} +4 -4
- package/dist/config-HMW3EBZL.js +2 -0
- package/dist/config-mutator-4SZ5YEVD.js +2 -0
- package/dist/{conflict-detector-D7VIYPDI.js → conflict-detector-ZPOT3M2C.js} +2 -2
- package/dist/conflict-detector-ZPOT3M2C.js.map +1 -0
- package/dist/context-planner-AQHJMVY2.js +17 -0
- package/dist/context-planner-AQHJMVY2.js.map +1 -0
- package/dist/debug-3MEUO2P2.js +2 -0
- package/dist/{discover-XJXQXXIO.js → discover-D4VVRNAI.js} +2 -2
- package/dist/discover-D4VVRNAI.js.map +1 -0
- package/dist/doctor-LRYV3QLK.js +2 -0
- package/dist/editor-chat-OB7IQOB5.js +137 -0
- package/dist/editor-chat-OB7IQOB5.js.map +1 -0
- package/dist/entry-triage-LK34TSQD.js +2 -0
- package/dist/entry-triage-LK34TSQD.js.map +1 -0
- package/dist/facts-YVEJOM7O.js +22 -0
- package/dist/facts-YVEJOM7O.js.map +1 -0
- package/dist/heal-FVLBHYP7.js +2 -0
- package/dist/index.d.ts +1040 -64
- package/dist/index.js +12 -19
- package/dist/index.js.map +1 -1
- package/dist/{ingest-whatsapp-LZE62INA.js → ingest-whatsapp-NS4OWDOU.js} +2 -2
- package/dist/loader-4ZGTK7LD.js +2 -0
- package/dist/loader-BOKE5M5S.js +2 -0
- package/dist/memory-extract-F5ZHWY5E.js +2 -0
- package/dist/{migrate-v2-QO7SLKIM.js → migrate-v2-V4WN2VVM.js} +2 -2
- package/dist/ntfy-X7EPB3TG.js +4 -0
- package/dist/ntfy-X7EPB3TG.js.map +1 -0
- package/dist/processes-WU23U2OJ.js +2 -0
- package/dist/program-GSZ5AYAM.js +2 -0
- package/dist/{projects-api-KRME2YKZ.js → projects-api-QE6L65SK.js} +2 -2
- package/dist/{projects-mutate-3NHVG437.js → projects-mutate-MY7JTLSZ.js} +2 -2
- package/dist/providers-KO2DODIO.js +2 -0
- package/dist/query-SQDHE4HV.js +2 -0
- package/dist/questions-4B4KSPXG.js +3 -0
- package/dist/questions-4B4KSPXG.js.map +1 -0
- package/dist/recipes-7S4MC5OJ.js +2 -0
- package/dist/registry-LE6FWYZC.js +2 -0
- package/dist/review-candidates-RQU5KNZM.js +4 -0
- package/dist/review-candidates-RQU5KNZM.js.map +1 -0
- package/dist/review-store-B4YE3EFK.js +4 -0
- package/dist/review-store-B4YE3EFK.js.map +1 -0
- package/dist/{rotation-memo-B3EXXSEH.js → rotation-memo-7T75BLEX.js} +2 -2
- package/dist/routing-ILERJXB6.js +2 -0
- package/dist/runner-YRBVNYTY.js +2 -0
- package/dist/scheduler-BFM3EINS.js +2 -0
- package/dist/seat-7WM7II2B.js +2 -0
- package/dist/select-X4KVMOP5.js +2 -0
- package/dist/select-X4KVMOP5.js.map +1 -0
- package/dist/store-MCOA6N4F.js +2 -0
- package/dist/{subagent-WV7QKQ3L.js → subagent-2SJHSSJS.js} +2 -2
- package/dist/{surface-usage-PRL6Z4U3.js → surface-usage-NS74AXSE.js} +2 -2
- package/dist/timers-PCB4ZIBD.js +2 -0
- package/dist/triage-backtest-JNO7RKX7.js +2 -0
- package/dist/triage-backtest-JNO7RKX7.js.map +1 -0
- package/dist/types-H5ENIUIT.js +2 -0
- package/dist/types-OIFU3KSQ.js +2 -0
- package/dist/types-OIFU3KSQ.js.map +1 -0
- package/dist/web/workflow-editor.global.js +1542 -0
- package/dist/web/workflow-editor.global.js.map +1 -0
- package/dist/{whatsapp-state-XMTCSRXU.js → whatsapp-state-6D6FHHD2.js} +2 -2
- package/dist/wiki-2AUFSIKO.js +2 -0
- package/dist/wiki-2AUFSIKO.js.map +1 -0
- package/dist/workflows/templates/branching.yaml +1 -1
- package/dist/{workspace-setup-TBQAHYHJ.js → workspace-setup-B3F3TUDE.js} +2 -2
- package/dist/workspace-setup-B3F3TUDE.js.map +1 -0
- package/package.json +14 -3
- package/scripts/postinstall.mjs +80 -0
- package/dist/agent-5QMITZT6.js +0 -2
- package/dist/architect-SEPWMWS4.js +0 -2
- package/dist/board-dashboard-M3372FLO.js.map +0 -1
- package/dist/chunk-2WVX3LEC.js.map +0 -1
- package/dist/chunk-3WQFCIB6.js +0 -24
- package/dist/chunk-3WQFCIB6.js.map +0 -1
- package/dist/chunk-4YCH6IZV.js +0 -2
- package/dist/chunk-53NRX2NA.js +0 -2
- package/dist/chunk-53NRX2NA.js.map +0 -1
- package/dist/chunk-5U35WAZF.js.map +0 -1
- package/dist/chunk-BFXONUUN.js +0 -108
- package/dist/chunk-BFXONUUN.js.map +0 -1
- package/dist/chunk-BFZUG66X.js +0 -45
- package/dist/chunk-BFZUG66X.js.map +0 -1
- package/dist/chunk-BTHHSY7D.js +0 -13
- package/dist/chunk-BTHHSY7D.js.map +0 -1
- package/dist/chunk-BVGJSPW4.js.map +0 -1
- package/dist/chunk-D46VZ7K7.js +0 -116
- package/dist/chunk-D46VZ7K7.js.map +0 -1
- package/dist/chunk-DJEZ2HVU.js.map +0 -1
- package/dist/chunk-E5CXCI33.js +0 -155
- package/dist/chunk-E5CXCI33.js.map +0 -1
- package/dist/chunk-FDWOECJY.js +0 -54
- package/dist/chunk-FDWOECJY.js.map +0 -1
- package/dist/chunk-H5D7ATXS.js.map +0 -1
- package/dist/chunk-HR4JSRZS.js +0 -3
- package/dist/chunk-HR4JSRZS.js.map +0 -1
- package/dist/chunk-ISEBLHWX.js +0 -118
- package/dist/chunk-ISEBLHWX.js.map +0 -1
- package/dist/chunk-JP5IEXR7.js +0 -32
- package/dist/chunk-JTJIPNXY.js.map +0 -1
- package/dist/chunk-K2CAPYES.js +0 -5
- package/dist/chunk-LDHPMJ7M.js.map +0 -1
- package/dist/chunk-METYYZKQ.js +0 -1115
- package/dist/chunk-METYYZKQ.js.map +0 -1
- package/dist/chunk-MTAPOCGA.js +0 -47
- package/dist/chunk-MTAPOCGA.js.map +0 -1
- package/dist/chunk-O6YPEOQF.js +0 -5
- package/dist/chunk-P4VWIVKN.js.map +0 -1
- package/dist/chunk-SR3X25TN.js +0 -3
- package/dist/chunk-SR3X25TN.js.map +0 -1
- package/dist/chunk-UHE5FCPB.js +0 -431
- package/dist/chunk-UHE5FCPB.js.map +0 -1
- package/dist/chunk-V3U4OF7L.js.map +0 -1
- package/dist/chunk-VJUTQAPJ.js +0 -2
- package/dist/chunk-VJUTQAPJ.js.map +0 -1
- package/dist/chunk-VT5XYFDD.js +0 -126
- package/dist/chunk-VT5XYFDD.js.map +0 -1
- package/dist/client-HGKAC6VP.js +0 -2
- package/dist/config-A4RWEJHV.js +0 -2
- package/dist/config-mutator-MNSFUN7D.js +0 -2
- package/dist/conflict-detector-D7VIYPDI.js.map +0 -1
- package/dist/context-planner-45GEWKGL.js +0 -17
- package/dist/context-planner-45GEWKGL.js.map +0 -1
- package/dist/debug-N3B5NVJU.js +0 -2
- package/dist/discover-XJXQXXIO.js.map +0 -1
- package/dist/doctor-3Q36ZFE6.js +0 -2
- package/dist/heal-HZ3EQD52.js +0 -2
- package/dist/loader-5CA3O5GM.js +0 -2
- package/dist/loader-6IHUCPVO.js +0 -2
- package/dist/memory-extract-AENFVVO5.js +0 -2
- package/dist/processes-UIC4XXA6.js +0 -2
- package/dist/program-SAUNRCCD.js +0 -2
- package/dist/providers-L27BJ5A6.js +0 -2
- package/dist/query-HZYYRCLU.js +0 -2
- package/dist/recipes-EPLE2SNT.js +0 -2
- package/dist/registry-HK3WQ7BS.js +0 -2
- package/dist/runner-BFYDWPUO.js +0 -2
- package/dist/scheduler-6T2SU5ST.js +0 -2
- package/dist/store-I3HEWVLQ.js +0 -2
- package/dist/timers-5LPZN55J.js +0 -2
- package/dist/types-VECTMYTO.js +0 -2
- package/dist/types-Y6ZCZ52Z.js +0 -2
- package/dist/wiki-M6YNRFC4.js +0 -2
- /package/dist/{App-BPNHIGUX.js.map → App-CKPH3HHF.js.map} +0 -0
- /package/dist/{ChatApp-6XSSZB4U.js.map → ChatApp-MX7IG4K2.js.map} +0 -0
- /package/dist/{agent-5QMITZT6.js.map → agent-2U574KBZ.js.map} +0 -0
- /package/dist/{architect-SEPWMWS4.js.map → article-fields-L4D2BOGY.js.map} +0 -0
- /package/dist/{audit-JU3HWISB.js.map → audit-4245CX6P.js.map} +0 -0
- /package/dist/{builtin-FRKAQMFS.js.map → builtin-B67DMJ3M.js.map} +0 -0
- /package/dist/{chunk-4YCH6IZV.js.map → calibration-ILS5OI44.js.map} +0 -0
- /package/dist/{client-HGKAC6VP.js.map → chunk-FIOB3MEX.js.map} +0 -0
- /package/dist/{config-A4RWEJHV.js.map → client-ZLSXGPTV.js.map} +0 -0
- /package/dist/{compaction-NWEMOSHA.js.map → compaction-FD4R23HZ.js.map} +0 -0
- /package/dist/{config-mutator-MNSFUN7D.js.map → config-HMW3EBZL.js.map} +0 -0
- /package/dist/{debug-N3B5NVJU.js.map → config-mutator-4SZ5YEVD.js.map} +0 -0
- /package/dist/{doctor-3Q36ZFE6.js.map → debug-3MEUO2P2.js.map} +0 -0
- /package/dist/{heal-HZ3EQD52.js.map → doctor-LRYV3QLK.js.map} +0 -0
- /package/dist/{loader-5CA3O5GM.js.map → heal-FVLBHYP7.js.map} +0 -0
- /package/dist/{ingest-whatsapp-LZE62INA.js.map → ingest-whatsapp-NS4OWDOU.js.map} +0 -0
- /package/dist/{loader-6IHUCPVO.js.map → loader-4ZGTK7LD.js.map} +0 -0
- /package/dist/{memory-extract-AENFVVO5.js.map → loader-BOKE5M5S.js.map} +0 -0
- /package/dist/{processes-UIC4XXA6.js.map → memory-extract-F5ZHWY5E.js.map} +0 -0
- /package/dist/{migrate-v2-QO7SLKIM.js.map → migrate-v2-V4WN2VVM.js.map} +0 -0
- /package/dist/{program-SAUNRCCD.js.map → processes-WU23U2OJ.js.map} +0 -0
- /package/dist/{providers-L27BJ5A6.js.map → program-GSZ5AYAM.js.map} +0 -0
- /package/dist/{projects-api-KRME2YKZ.js.map → projects-api-QE6L65SK.js.map} +0 -0
- /package/dist/{projects-mutate-3NHVG437.js.map → projects-mutate-MY7JTLSZ.js.map} +0 -0
- /package/dist/{query-HZYYRCLU.js.map → providers-KO2DODIO.js.map} +0 -0
- /package/dist/{recipes-EPLE2SNT.js.map → query-SQDHE4HV.js.map} +0 -0
- /package/dist/{registry-HK3WQ7BS.js.map → recipes-7S4MC5OJ.js.map} +0 -0
- /package/dist/{runner-BFYDWPUO.js.map → registry-LE6FWYZC.js.map} +0 -0
- /package/dist/{rotation-memo-B3EXXSEH.js.map → rotation-memo-7T75BLEX.js.map} +0 -0
- /package/dist/{scheduler-6T2SU5ST.js.map → routing-ILERJXB6.js.map} +0 -0
- /package/dist/{store-I3HEWVLQ.js.map → runner-YRBVNYTY.js.map} +0 -0
- /package/dist/{surface-usage-PRL6Z4U3.js.map → scheduler-BFM3EINS.js.map} +0 -0
- /package/dist/{timers-5LPZN55J.js.map → seat-7WM7II2B.js.map} +0 -0
- /package/dist/{types-VECTMYTO.js.map → store-MCOA6N4F.js.map} +0 -0
- /package/dist/{subagent-WV7QKQ3L.js.map → subagent-2SJHSSJS.js.map} +0 -0
- /package/dist/{types-Y6ZCZ52Z.js.map → surface-usage-NS74AXSE.js.map} +0 -0
- /package/dist/{wiki-M6YNRFC4.js.map → timers-PCB4ZIBD.js.map} +0 -0
- /package/dist/{workspace-setup-TBQAHYHJ.js.map → types-H5ENIUIT.js.map} +0 -0
- /package/dist/{whatsapp-state-XMTCSRXU.js.map → whatsapp-state-6D6FHHD2.js.map} +0 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// Synthetic typing and key presses.
|
|
4
|
+
///
|
|
5
|
+
/// This is the first verb here that CHANGES something rather than pointing
|
|
6
|
+
/// at it, so it is worth being explicit about what that means: these
|
|
7
|
+
/// events are indistinguishable from the keyboard to every application,
|
|
8
|
+
/// they go wherever focus happens to be, and nothing downstream can tell
|
|
9
|
+
/// them from a person. There is no undo.
|
|
10
|
+
///
|
|
11
|
+
/// Two consequences are built in rather than left to callers. Typing is
|
|
12
|
+
/// paced like a hand, so a field with its own debouncing or autocomplete
|
|
13
|
+
/// behaves the way it would for a person instead of receiving a paste-like
|
|
14
|
+
/// burst it never sees in production. And `submit` is a separate verb from
|
|
15
|
+
/// `type`, so committing a form is always its own deliberate call — never
|
|
16
|
+
/// a trailing newline that slipped into some text.
|
|
17
|
+
enum Typer {
|
|
18
|
+
|
|
19
|
+
/// Type text at human cadence.
|
|
20
|
+
///
|
|
21
|
+
/// CGEventKeyboardSetUnicodeString rather than keycodes: keycodes are
|
|
22
|
+
/// layout-dependent, so the same call produces different characters on
|
|
23
|
+
/// an AZERTY machine, and this host is French.
|
|
24
|
+
static func type(_ text: String, wpm: Double = 260) {
|
|
25
|
+
// A character every ~46ms at 260wpm — brisk but visibly typed, so
|
|
26
|
+
// someone watching a lesson can read along.
|
|
27
|
+
let base = 60.0 / (wpm * 5.0)
|
|
28
|
+
let source = CGEventSource(stateID: .combinedSessionState)
|
|
29
|
+
|
|
30
|
+
for ch in text {
|
|
31
|
+
guard let down = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true),
|
|
32
|
+
let up = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false)
|
|
33
|
+
else { continue }
|
|
34
|
+
var utf16 = Array(String(ch).utf16)
|
|
35
|
+
down.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: &utf16)
|
|
36
|
+
up.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: &utf16)
|
|
37
|
+
down.post(tap: .cghidEventTap)
|
|
38
|
+
up.post(tap: .cghidEventTap)
|
|
39
|
+
|
|
40
|
+
// Jitter, and a longer pause after a space — real typing is
|
|
41
|
+
// bursty at word boundaries, and a metronome is the tell.
|
|
42
|
+
var delay = base * Double.random(in: 0.65...1.45)
|
|
43
|
+
if ch == " " { delay += base * 0.9 }
|
|
44
|
+
Thread.sleep(forTimeInterval: delay)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Named keys, for the things typing cannot express.
|
|
49
|
+
private static let keyCodes: [String: CGKeyCode] = [
|
|
50
|
+
"return": 36, "enter": 36, "tab": 48, "space": 49,
|
|
51
|
+
"delete": 51, "escape": 53, "esc": 53,
|
|
52
|
+
"left": 123, "right": 124, "down": 125, "up": 126,
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
static func press(_ name: String) -> Bool {
|
|
56
|
+
guard let code = keyCodes[name.lowercased()] else { return false }
|
|
57
|
+
let source = CGEventSource(stateID: .combinedSessionState)
|
|
58
|
+
CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: true)?.post(tap: .cghidEventTap)
|
|
59
|
+
Thread.sleep(forTimeInterval: 0.035)
|
|
60
|
+
CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: false)?.post(tap: .cghidEventTap)
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Modifier names accepted by `key` and the click verbs.
|
|
65
|
+
static let modifierFlags: [String: CGEventFlags] = [
|
|
66
|
+
"cmd": .maskCommand, "command": .maskCommand,
|
|
67
|
+
"shift": .maskShift,
|
|
68
|
+
"opt": .maskAlternate, "option": .maskAlternate, "alt": .maskAlternate,
|
|
69
|
+
"ctrl": .maskControl, "control": .maskControl,
|
|
70
|
+
"fn": .maskSecondaryFn,
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
static func flags(from names: [String]) -> CGEventFlags {
|
|
74
|
+
var f = CGEventFlags()
|
|
75
|
+
for n in names { if let m = modifierFlags[n.lowercased()] { f.insert(m) } }
|
|
76
|
+
return f
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Press a named key with optional modifiers, e.g. cmd+a.
|
|
80
|
+
static func press(_ name: String, modifiers: [String] = []) -> Bool {
|
|
81
|
+
guard let code = keyCodes[name.lowercased()] ?? letterCode(name) else { return false }
|
|
82
|
+
let source = CGEventSource(stateID: .combinedSessionState)
|
|
83
|
+
let mods = flags(from: modifiers)
|
|
84
|
+
let down = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: true)
|
|
85
|
+
let up = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: false)
|
|
86
|
+
down?.flags = mods
|
|
87
|
+
up?.flags = mods
|
|
88
|
+
down?.post(tap: .cghidEventTap)
|
|
89
|
+
Thread.sleep(forTimeInterval: 0.035)
|
|
90
|
+
up?.post(tap: .cghidEventTap)
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Single letters and digits, so `key --name a --mod cmd` works.
|
|
95
|
+
private static func letterCode(_ name: String) -> CGKeyCode? {
|
|
96
|
+
let map: [Character: CGKeyCode] = [
|
|
97
|
+
"a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9,
|
|
98
|
+
"b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17,
|
|
99
|
+
"1": 18, "2": 19, "3": 20, "4": 21, "6": 22, "5": 23, "9": 25, "7": 26, "8": 28, "0": 29,
|
|
100
|
+
"o": 31, "u": 32, "i": 34, "p": 35, "l": 37, "j": 38, "k": 40, "n": 45, "m": 46,
|
|
101
|
+
]
|
|
102
|
+
guard name.count == 1, let ch = name.lowercased().first else { return nil }
|
|
103
|
+
return map[ch]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// MARK: Mouse
|
|
107
|
+
|
|
108
|
+
/// Click where the cursor already is.
|
|
109
|
+
///
|
|
110
|
+
/// Separate from `point` on purpose: point is safe to run against
|
|
111
|
+
/// anything, and keeping the click a distinct call means nothing
|
|
112
|
+
/// clicks merely because it was located.
|
|
113
|
+
static func click(button: String = "left", clicks: Int = 1, modifiers: [String] = []) {
|
|
114
|
+
let p = CGEvent(source: nil)?.location ?? .zero
|
|
115
|
+
let source = CGEventSource(stateID: .combinedSessionState)
|
|
116
|
+
let right = button.lowercased() == "right"
|
|
117
|
+
let downType: CGEventType = right ? .rightMouseDown : .leftMouseDown
|
|
118
|
+
let upType: CGEventType = right ? .rightMouseUp : .leftMouseUp
|
|
119
|
+
let mouseButton: CGMouseButton = right ? .right : .left
|
|
120
|
+
let mods = flags(from: modifiers)
|
|
121
|
+
|
|
122
|
+
for i in 1...max(1, clicks) {
|
|
123
|
+
let down = CGEvent(mouseEventSource: source, mouseType: downType,
|
|
124
|
+
mouseCursorPosition: p, mouseButton: mouseButton)
|
|
125
|
+
let up = CGEvent(mouseEventSource: source, mouseType: upType,
|
|
126
|
+
mouseCursorPosition: p, mouseButton: mouseButton)
|
|
127
|
+
// clickState is what makes the OS read two clicks as a
|
|
128
|
+
// double-click rather than two separate ones.
|
|
129
|
+
down?.setIntegerValueField(.mouseEventClickState, value: Int64(i))
|
|
130
|
+
up?.setIntegerValueField(.mouseEventClickState, value: Int64(i))
|
|
131
|
+
down?.flags = mods
|
|
132
|
+
up?.flags = mods
|
|
133
|
+
down?.post(tap: .cghidEventTap)
|
|
134
|
+
Thread.sleep(forTimeInterval: Double.random(in: 0.05...0.09))
|
|
135
|
+
up?.post(tap: .cghidEventTap)
|
|
136
|
+
// Inside the system double-click interval, or the second click
|
|
137
|
+
// starts a new sequence.
|
|
138
|
+
if i < clicks { Thread.sleep(forTimeInterval: 0.08) }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Scroll by wheel units at the cursor. Negative dy scrolls down.
|
|
143
|
+
static func scroll(dx: Int32 = 0, dy: Int32 = 0, steps: Int = 10) {
|
|
144
|
+
let source = CGEventSource(stateID: .combinedSessionState)
|
|
145
|
+
// Broken into steps with easing: one large wheel event jumps, and
|
|
146
|
+
// momentum-scrolling views ignore it entirely.
|
|
147
|
+
let n = max(1, steps)
|
|
148
|
+
for i in 1...n {
|
|
149
|
+
let t = Double(i) / Double(n)
|
|
150
|
+
let ease = 1 - pow(1 - t, 2)
|
|
151
|
+
let prev = Double(i - 1) / Double(n)
|
|
152
|
+
let prevEase = 1 - pow(1 - prev, 2)
|
|
153
|
+
let stepY = Int32((Double(dy) * (ease - prevEase)).rounded())
|
|
154
|
+
let stepX = Int32((Double(dx) * (ease - prevEase)).rounded())
|
|
155
|
+
if stepX == 0 && stepY == 0 { continue }
|
|
156
|
+
CGEvent(scrollWheelEvent2Source: source, units: .pixel,
|
|
157
|
+
wheelCount: 2, wheel1: stepY, wheel2: stepX, wheel3: 0)?
|
|
158
|
+
.post(tap: .cghidEventTap)
|
|
159
|
+
Thread.sleep(forTimeInterval: 0.016)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Press at the current point, move, release. Used for sliders,
|
|
164
|
+
/// reordering and selection.
|
|
165
|
+
static func drag(to target: CGPoint, durationSeconds: Double = 0.6) {
|
|
166
|
+
let from = CGEvent(source: nil)?.location ?? target
|
|
167
|
+
let source = CGEventSource(stateID: .combinedSessionState)
|
|
168
|
+
CGEvent(mouseEventSource: source, mouseType: .leftMouseDown,
|
|
169
|
+
mouseCursorPosition: from, mouseButton: .left)?.post(tap: .cghidEventTap)
|
|
170
|
+
Thread.sleep(forTimeInterval: 0.06)
|
|
171
|
+
|
|
172
|
+
let steps = max(12, Int(durationSeconds * 90))
|
|
173
|
+
for i in 1...steps {
|
|
174
|
+
let t = Double(i) / Double(steps)
|
|
175
|
+
let e = 10 * pow(t, 3) - 15 * pow(t, 4) + 6 * pow(t, 5)
|
|
176
|
+
let p = CGPoint(x: from.x + (target.x - from.x) * e,
|
|
177
|
+
y: from.y + (target.y - from.y) * e)
|
|
178
|
+
CGEvent(mouseEventSource: source, mouseType: .leftMouseDragged,
|
|
179
|
+
mouseCursorPosition: p, mouseButton: .left)?.post(tap: .cghidEventTap)
|
|
180
|
+
Thread.sleep(forTimeInterval: durationSeconds / Double(steps))
|
|
181
|
+
}
|
|
182
|
+
CGEvent(mouseEventSource: source, mouseType: .leftMouseUp,
|
|
183
|
+
mouseCursorPosition: target, mouseButton: .left)?.post(tap: .cghidEventTap)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import ApplicationServices
|
|
3
|
+
|
|
4
|
+
/// Eyes. Two of them, because "can I see it" and "did it work" are
|
|
5
|
+
/// different questions with different right answers.
|
|
6
|
+
///
|
|
7
|
+
/// Everything else in this helper reads the accessibility tree, which
|
|
8
|
+
/// reports what EXISTS and where, and says nothing about what is VISIBLE.
|
|
9
|
+
/// Z-order, overlays, opacity, a modal on top, something scrolled out of
|
|
10
|
+
/// view — all invisible to it. An element can be reported at (218, 145)
|
|
11
|
+
/// with perfect confidence while something else sits on top of it.
|
|
12
|
+
///
|
|
13
|
+
/// That is not hypothetical. This tool's own HUD covered the search field
|
|
14
|
+
/// it was pointing at, and nothing in the pipeline could tell. The
|
|
15
|
+
/// obstruction was self-inflicted and still undetectable.
|
|
16
|
+
///
|
|
17
|
+
/// So:
|
|
18
|
+
///
|
|
19
|
+
/// hitTest — is the thing I mean actually on top at that point?
|
|
20
|
+
/// Uses the accessibility hit test, which is exact and costs
|
|
21
|
+
/// no pixels. This is the check that catches occlusion.
|
|
22
|
+
///
|
|
23
|
+
/// capture — what does this region look like right now? Used in pairs,
|
|
24
|
+
/// before and after, to answer "did anything change" — which
|
|
25
|
+
/// is the only honest way to claim an action landed.
|
|
26
|
+
enum Vision {
|
|
27
|
+
|
|
28
|
+
// MARK: Occlusion
|
|
29
|
+
|
|
30
|
+
struct HitResult: Codable {
|
|
31
|
+
/// Role of whatever is topmost at the point.
|
|
32
|
+
let role: String
|
|
33
|
+
let label: String
|
|
34
|
+
/// The frontmost app owning it — a different app here usually means
|
|
35
|
+
/// a floating panel is in the way.
|
|
36
|
+
let app: String
|
|
37
|
+
/// True when the topmost element's frame matches the one asked
|
|
38
|
+
/// about, within a tolerance.
|
|
39
|
+
let matchesExpected: Bool?
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// What is actually on top at a screen point.
|
|
43
|
+
///
|
|
44
|
+
/// AXUIElementCopyElementAtPosition resolves through the window server,
|
|
45
|
+
/// so it sees exactly what a click would hit — including overlays this
|
|
46
|
+
/// process put there itself.
|
|
47
|
+
static func hitTest(x: Double, y: Double, expect: CGRect? = nil) -> HitResult? {
|
|
48
|
+
let system = AXUIElementCreateSystemWide()
|
|
49
|
+
var ref: AXUIElement?
|
|
50
|
+
guard AXUIElementCopyElementAtPosition(system, Float(x), Float(y), &ref) == .success,
|
|
51
|
+
let element = ref
|
|
52
|
+
else { return nil }
|
|
53
|
+
|
|
54
|
+
let role = attr(element, kAXRoleAttribute) ?? ""
|
|
55
|
+
let label = [kAXTitleAttribute, kAXDescriptionAttribute, kAXValueAttribute]
|
|
56
|
+
.compactMap { attr(element, $0) }
|
|
57
|
+
.first(where: { !$0.isEmpty }) ?? ""
|
|
58
|
+
|
|
59
|
+
var owner = "unknown"
|
|
60
|
+
var pid: pid_t = 0
|
|
61
|
+
if AXUIElementGetPid(element, &pid) == .success,
|
|
62
|
+
let app = NSRunningApplication(processIdentifier: pid) {
|
|
63
|
+
owner = app.localizedName ?? "unknown"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
var matches: Bool?
|
|
67
|
+
if let expect {
|
|
68
|
+
// Compare frames rather than identity: AXUIElement references
|
|
69
|
+
// are not stable across calls, so two handles to the same
|
|
70
|
+
// control are not equal even when they should be.
|
|
71
|
+
if let f = frame(element) {
|
|
72
|
+
let dx = abs(f.midX - expect.midX), dy = abs(f.midY - expect.midY)
|
|
73
|
+
// Generous, because a hit inside the intended control is a
|
|
74
|
+
// hit even if the tree reports a child of it.
|
|
75
|
+
matches = dx <= max(expect.width, 24) && dy <= max(expect.height, 24)
|
|
76
|
+
} else {
|
|
77
|
+
matches = false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return HitResult(role: role, label: label, app: owner, matchesExpected: matches)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// MARK: Capture
|
|
84
|
+
|
|
85
|
+
/// Grab a screen region to a PNG.
|
|
86
|
+
///
|
|
87
|
+
/// CGWindowListCreateImage rather than ScreenCaptureKit: this is a
|
|
88
|
+
/// one-shot CLI, and SCK's async setup costs more than the capture. It
|
|
89
|
+
/// needs Screen Recording permission; without it macOS returns a
|
|
90
|
+
/// desktop-only image rather than failing, which is why the caller is
|
|
91
|
+
/// told to check for an all-uniform result.
|
|
92
|
+
///
|
|
93
|
+
/// `maxPixels` caps the TOTAL area before writing, and `maxWidth` the
|
|
94
|
+
/// long edge. Prefer the area budget.
|
|
95
|
+
///
|
|
96
|
+
/// Cost tracks pixel count, not width, and capping width alone throws
|
|
97
|
+
/// away detail on small regions while saving nothing. A menu bar strip
|
|
98
|
+
/// is 2880x52 on this display — 150k pixels, a couple of hundred
|
|
99
|
+
/// tokens — and scaling it to 1400 wide halves the resolution of the
|
|
100
|
+
/// only thing in it worth seeing. A full screen is 5.2M pixels and
|
|
101
|
+
/// genuinely does need cutting. One rule covers both: leave it alone
|
|
102
|
+
/// unless it is actually big.
|
|
103
|
+
///
|
|
104
|
+
/// Resolution here is not a cost knob, it is the difference between a
|
|
105
|
+
/// legible indicator and a grey smudge. macOS draws the screen-
|
|
106
|
+
/// recording dot at about six points.
|
|
107
|
+
@discardableResult
|
|
108
|
+
static func capture(_ rect: CGRect, to path: String,
|
|
109
|
+
maxWidth: Int? = nil, maxPixels: Int? = nil) -> Bool {
|
|
110
|
+
guard let image = CGWindowListCreateImage(
|
|
111
|
+
rect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution])
|
|
112
|
+
else { return false }
|
|
113
|
+
var target = maxWidth
|
|
114
|
+
if let budget = maxPixels, budget > 0 {
|
|
115
|
+
let area = image.width * image.height
|
|
116
|
+
if area > budget {
|
|
117
|
+
// Preserve aspect: width * (width / aspect) = budget.
|
|
118
|
+
let scale = (Double(budget) / Double(area)).squareRoot()
|
|
119
|
+
let fit = max(1, Int(Double(image.width) * scale))
|
|
120
|
+
target = min(target ?? fit, fit)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
let out = target.flatMap { downscale(image, maxWidth: $0) } ?? image
|
|
124
|
+
let rep = NSBitmapImageRep(cgImage: out)
|
|
125
|
+
guard let png = rep.representation(using: .png, properties: [:]) else { return false }
|
|
126
|
+
return (try? png.write(to: URL(fileURLWithPath: path))) != nil
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Returns nil when the image is already narrow enough, so the caller
|
|
130
|
+
/// falls back to the original rather than paying for a pointless redraw.
|
|
131
|
+
private static func downscale(_ image: CGImage, maxWidth: Int) -> CGImage? {
|
|
132
|
+
guard maxWidth > 0, image.width > maxWidth else { return nil }
|
|
133
|
+
let height = max(1, Int(Double(image.height) * Double(maxWidth) / Double(image.width)))
|
|
134
|
+
guard let ctx = CGContext(
|
|
135
|
+
data: nil, width: maxWidth, height: height,
|
|
136
|
+
bitsPerComponent: 8, bytesPerRow: 0,
|
|
137
|
+
space: CGColorSpaceCreateDeviceRGB(),
|
|
138
|
+
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
|
|
139
|
+
else { return nil }
|
|
140
|
+
// High, not low: this image exists to be READ. Cheap interpolation
|
|
141
|
+
// turns small text into grey mush and the model then reports an
|
|
142
|
+
// empty menu bar with complete confidence.
|
|
143
|
+
ctx.interpolationQuality = .high
|
|
144
|
+
ctx.draw(image, in: CGRect(x: 0, y: 0, width: maxWidth, height: height))
|
|
145
|
+
return ctx.makeImage()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Mean absolute difference between two regions, 0…1.
|
|
149
|
+
///
|
|
150
|
+
/// Downsamples hard before comparing: the question is "did this change
|
|
151
|
+
/// meaningfully", not "is a single antialiased pixel different", and a
|
|
152
|
+
/// small grid also makes the comparison immune to a one-pixel shift.
|
|
153
|
+
static func difference(_ a: CGRect, _ b: CGImage?) -> Double? {
|
|
154
|
+
guard let now = CGWindowListCreateImage(a, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution]),
|
|
155
|
+
let before = b
|
|
156
|
+
else { return nil }
|
|
157
|
+
guard let g1 = grid(before), let g2 = grid(now), g1.count == g2.count, !g1.isEmpty
|
|
158
|
+
else { return nil }
|
|
159
|
+
var total = 0.0
|
|
160
|
+
for i in 0..<g1.count { total += abs(Double(g1[i]) - Double(g2[i])) / 255.0 }
|
|
161
|
+
return total / Double(g1.count)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
static func snapshot(_ rect: CGRect) -> CGImage? {
|
|
165
|
+
CGWindowListCreateImage(rect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution])
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// 16×16 greyscale, which is plenty to notice a page changing and
|
|
169
|
+
/// cheap enough to run between every action.
|
|
170
|
+
private static func grid(_ image: CGImage, side: Int = 16) -> [UInt8]? {
|
|
171
|
+
let space = CGColorSpaceCreateDeviceGray()
|
|
172
|
+
var pixels = [UInt8](repeating: 0, count: side * side)
|
|
173
|
+
guard let ctx = CGContext(data: &pixels, width: side, height: side,
|
|
174
|
+
bitsPerComponent: 8, bytesPerRow: side,
|
|
175
|
+
space: space, bitmapInfo: 0)
|
|
176
|
+
else { return nil }
|
|
177
|
+
ctx.interpolationQuality = .low
|
|
178
|
+
ctx.draw(image, in: CGRect(x: 0, y: 0, width: side, height: side))
|
|
179
|
+
return pixels
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// MARK: Helpers
|
|
183
|
+
|
|
184
|
+
private static func attr(_ node: AXUIElement, _ name: String) -> String? {
|
|
185
|
+
var ref: CFTypeRef?
|
|
186
|
+
guard AXUIElementCopyAttributeValue(node, name as CFString, &ref) == .success else { return nil }
|
|
187
|
+
if let s = ref as? String { return s.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
188
|
+
return nil
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private static func frame(_ node: AXUIElement) -> CGRect? {
|
|
192
|
+
var posRef: CFTypeRef?, sizeRef: CFTypeRef?
|
|
193
|
+
guard AXUIElementCopyAttributeValue(node, kAXPositionAttribute as CFString, &posRef) == .success,
|
|
194
|
+
AXUIElementCopyAttributeValue(node, kAXSizeAttribute as CFString, &sizeRef) == .success
|
|
195
|
+
else { return nil }
|
|
196
|
+
var p = CGPoint.zero, s = CGSize.zero
|
|
197
|
+
AXValueGetValue(posRef as! AXValue, .cgPoint, &p)
|
|
198
|
+
AXValueGetValue(sizeRef as! AXValue, .cgSize, &s)
|
|
199
|
+
return CGRect(origin: p, size: s)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
// agentx-mac-helper — the native half of computer use.
|
|
4
|
+
//
|
|
5
|
+
// Swift owns only what Node cannot reach: the Accessibility API and the
|
|
6
|
+
// window server. Everything else — which element to choose, whether the
|
|
7
|
+
// operation is allowed, what to do next — stays in agentx, so the
|
|
8
|
+
// capability belongs to every agent rather than to one app.
|
|
9
|
+
//
|
|
10
|
+
// Two verbs in this cut, and deliberately no third:
|
|
11
|
+
//
|
|
12
|
+
// read dump the focused window's accessibility tree as JSON
|
|
13
|
+
// point move the cursor to an element and highlight it
|
|
14
|
+
//
|
|
15
|
+
// There is no `click`. The point tier makes a model's choice visible and
|
|
16
|
+
// reviewable before anything irreversible happens, which is worth having
|
|
17
|
+
// on its own and is the only tier that is safe while the guard is still
|
|
18
|
+
// soaking.
|
|
19
|
+
|
|
20
|
+
func fail(_ message: String, code: Int32 = 1) -> Never {
|
|
21
|
+
let payload = ["ok": false, "error": message] as [String: Any]
|
|
22
|
+
if let d = try? JSONSerialization.data(withJSONObject: payload) {
|
|
23
|
+
FileHandle.standardOutput.write(d)
|
|
24
|
+
}
|
|
25
|
+
exit(code)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let args = Array(CommandLine.arguments.dropFirst())
|
|
29
|
+
guard let verb = args.first else {
|
|
30
|
+
fail("usage: agentx-mac-helper read | point --x N --y N --w N --h N [--label TEXT] [--hold SECONDS] [--instant] | trusted")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func flag(_ name: String) -> String? {
|
|
34
|
+
guard let i = args.firstIndex(of: "--\(name)"), i + 1 < args.count else { return nil }
|
|
35
|
+
return args[i + 1]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
switch verb {
|
|
39
|
+
case "read":
|
|
40
|
+
guard AXTree.trusted() else {
|
|
41
|
+
fail("accessibility permission not granted — System Settings › Privacy & Security › Accessibility, then add this binary")
|
|
42
|
+
}
|
|
43
|
+
let snap = AXTree.snapshot(maxElements: Int(flag("max") ?? "400") ?? 400)
|
|
44
|
+
let encoder = JSONEncoder()
|
|
45
|
+
encoder.outputFormatting = [.withoutEscapingSlashes]
|
|
46
|
+
guard let data = try? encoder.encode(snap) else { fail("failed to encode tree") }
|
|
47
|
+
FileHandle.standardOutput.write(data)
|
|
48
|
+
|
|
49
|
+
case "point":
|
|
50
|
+
guard let x = Double(flag("x") ?? ""), let y = Double(flag("y") ?? ""),
|
|
51
|
+
let w = Double(flag("w") ?? ""), let h = Double(flag("h") ?? "") else {
|
|
52
|
+
fail("point needs --x --y --w --h")
|
|
53
|
+
}
|
|
54
|
+
Pointer.point(x: x, y: y, width: w, height: h,
|
|
55
|
+
label: flag("label") ?? "",
|
|
56
|
+
// Hold long enough to be seen, short enough not to be an
|
|
57
|
+
// obstruction. 0 is useful for scripted sequences where
|
|
58
|
+
// the next point follows immediately.
|
|
59
|
+
holdSeconds: Double(flag("hold") ?? "") ?? 2.5,
|
|
60
|
+
// Skip the human-speed move when something is stepping
|
|
61
|
+
// through many targets and the motion would just be slow.
|
|
62
|
+
instant: args.contains("--instant"))
|
|
63
|
+
FileHandle.standardOutput.write(#"{"ok":true,"pointed":true}"#.data(using: .utf8)!)
|
|
64
|
+
|
|
65
|
+
case "hittest":
|
|
66
|
+
// What is actually on top at this point. The occlusion check.
|
|
67
|
+
guard let x = Double(flag("x") ?? ""), let y = Double(flag("y") ?? "") else {
|
|
68
|
+
fail("hittest needs --x --y")
|
|
69
|
+
}
|
|
70
|
+
var expect: CGRect?
|
|
71
|
+
if let ex = Double(flag("ex") ?? ""), let ey = Double(flag("ey") ?? ""),
|
|
72
|
+
let ew = Double(flag("ew") ?? ""), let eh = Double(flag("eh") ?? "") {
|
|
73
|
+
expect = CGRect(x: ex, y: ey, width: ew, height: eh)
|
|
74
|
+
}
|
|
75
|
+
guard let hit = Vision.hitTest(x: x, y: y, expect: expect) else {
|
|
76
|
+
fail("nothing at (\(Int(x)), \(Int(y)))")
|
|
77
|
+
}
|
|
78
|
+
guard let hd = try? JSONEncoder().encode(hit) else { fail("failed to encode hit") }
|
|
79
|
+
FileHandle.standardOutput.write(hd)
|
|
80
|
+
|
|
81
|
+
case "ocr":
|
|
82
|
+
// Reads the focused window unless a rect is given. Coordinates come
|
|
83
|
+
// back in the SAME convention as `read`, so a caller can point at an
|
|
84
|
+
// OCR hit without converting anything.
|
|
85
|
+
let region: CGRect
|
|
86
|
+
if let x = Double(flag("x") ?? ""), let y = Double(flag("y") ?? ""),
|
|
87
|
+
let w = Double(flag("w") ?? ""), let h = Double(flag("h") ?? "") {
|
|
88
|
+
region = CGRect(x: x, y: y, width: w, height: h)
|
|
89
|
+
} else if let win = OCR.focusedWindowFrame() {
|
|
90
|
+
region = win
|
|
91
|
+
} else {
|
|
92
|
+
fail("no focused window and no --x/--y/--w/--h given")
|
|
93
|
+
}
|
|
94
|
+
let langs = (flag("lang") ?? "fr-FR,en-US").split(separator: ",").map(String.init)
|
|
95
|
+
let hits = OCR.read(rect: region, languages: langs)
|
|
96
|
+
let payload: [String: Any] = [
|
|
97
|
+
"ok": true,
|
|
98
|
+
"region": ["x": region.minX, "y": region.minY, "w": region.width, "h": region.height],
|
|
99
|
+
"count": hits.count,
|
|
100
|
+
"hits": hits.map { ["text": $0.text, "x": $0.x, "y": $0.y,
|
|
101
|
+
"width": $0.width, "height": $0.height,
|
|
102
|
+
"confidence": $0.confidence] },
|
|
103
|
+
]
|
|
104
|
+
FileHandle.standardOutput.write(
|
|
105
|
+
(try? JSONSerialization.data(withJSONObject: payload)) ?? Data())
|
|
106
|
+
|
|
107
|
+
case "capture":
|
|
108
|
+
// Region precedence: an explicit rect, else a named region of a
|
|
109
|
+
// screen, else the focused window. "Whole screen" has to be reachable
|
|
110
|
+
// without the caller knowing the display's size, because the state
|
|
111
|
+
// worth checking — a recording indicator, a capture pill — lives in
|
|
112
|
+
// the menu bar, which belongs to no window at all.
|
|
113
|
+
guard let out = flag("out") else { fail("capture needs --out") }
|
|
114
|
+
let screen = Screens.list().first(where: { $0.index == Int(flag("screen") ?? "") })
|
|
115
|
+
?? Screens.active()
|
|
116
|
+
let region: CGRect
|
|
117
|
+
if let x = Double(flag("x") ?? ""), let y = Double(flag("y") ?? ""),
|
|
118
|
+
let w = Double(flag("w") ?? ""), let h = Double(flag("h") ?? "") {
|
|
119
|
+
region = CGRect(x: x, y: y, width: w, height: h)
|
|
120
|
+
} else if args.contains("--menubar") {
|
|
121
|
+
guard let s = screen else { fail("no screen found") }
|
|
122
|
+
region = Screens.menuBar(s)
|
|
123
|
+
} else if args.contains("--screen-full") {
|
|
124
|
+
guard let s = screen else { fail("no screen found") }
|
|
125
|
+
region = Screens.rect(s)
|
|
126
|
+
} else if let win = OCR.focusedWindowFrame() {
|
|
127
|
+
region = win
|
|
128
|
+
} else {
|
|
129
|
+
fail("no focused window — pass --x/--y/--w/--h, --menubar or --screen-full")
|
|
130
|
+
}
|
|
131
|
+
guard Vision.capture(region, to: out,
|
|
132
|
+
maxWidth: Int(flag("max-width") ?? ""),
|
|
133
|
+
maxPixels: Int(flag("max-pixels") ?? "")) else {
|
|
134
|
+
fail("capture failed — check Screen Recording permission")
|
|
135
|
+
}
|
|
136
|
+
let shot: [String: Any] = [
|
|
137
|
+
"ok": true, "captured": true, "path": out,
|
|
138
|
+
"region": ["x": region.minX, "y": region.minY,
|
|
139
|
+
"w": region.width, "h": region.height],
|
|
140
|
+
]
|
|
141
|
+
FileHandle.standardOutput.write(
|
|
142
|
+
(try? JSONSerialization.data(withJSONObject: shot)) ?? Data())
|
|
143
|
+
|
|
144
|
+
case "screens":
|
|
145
|
+
// Display geometry in top-left coordinates, so a caller can aim at a
|
|
146
|
+
// second monitor without redoing AppKit's origin flip.
|
|
147
|
+
guard let d = try? JSONEncoder().encode(Screens.list()) else { fail("failed to encode screens") }
|
|
148
|
+
FileHandle.standardOutput.write(d)
|
|
149
|
+
|
|
150
|
+
case "focused":
|
|
151
|
+
// What has keyboard focus right now. The safety check before typing.
|
|
152
|
+
guard let f = Focus.current() else { fail("nothing focused") }
|
|
153
|
+
let enc = JSONEncoder()
|
|
154
|
+
guard let d = try? enc.encode(f) else { fail("failed to encode focus") }
|
|
155
|
+
FileHandle.standardOutput.write(d)
|
|
156
|
+
|
|
157
|
+
case "type":
|
|
158
|
+
// Typing goes wherever focus is, and cannot tell a search box from a
|
|
159
|
+
// post composer. Refuses by default when focus looks publishable —
|
|
160
|
+
// this exists because a lesson once got one keystroke from posting a
|
|
161
|
+
// search query to a public timeline.
|
|
162
|
+
guard let text = flag("text") else { fail("type needs --text") }
|
|
163
|
+
if !args.contains("--force") {
|
|
164
|
+
guard let f = Focus.current() else {
|
|
165
|
+
fail("refusing to type: nothing has keyboard focus")
|
|
166
|
+
}
|
|
167
|
+
if !f.editable {
|
|
168
|
+
fail("refusing to type: focus is \(f.role) \"\(f.label)\", which does not accept text")
|
|
169
|
+
}
|
|
170
|
+
if f.publishRisk {
|
|
171
|
+
fail("refusing to type: focus looks like a compose or send field (\(f.role) \"\(f.label)\") — pass --force only if publishing is intended")
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
Typer.type(text, wpm: Double(flag("wpm") ?? "") ?? 260)
|
|
175
|
+
FileHandle.standardOutput.write(#"{"ok":true,"typed":true}"#.data(using: .utf8)!)
|
|
176
|
+
|
|
177
|
+
case "key":
|
|
178
|
+
guard let name = flag("name") else { fail("key needs --name (return, tab, escape, a…z, 0…9)") }
|
|
179
|
+
// Return is the commit key. Pressing it into a composer publishes.
|
|
180
|
+
if !args.contains("--force"), ["return", "enter"].contains(name.lowercased()),
|
|
181
|
+
let f = Focus.current(), f.publishRisk {
|
|
182
|
+
fail("refusing to press \(name): focus looks like a compose or send field (\(f.role) \"\(f.label)\")")
|
|
183
|
+
}
|
|
184
|
+
let mods = (flag("mod") ?? "").split(separator: "+").map(String.init)
|
|
185
|
+
guard Typer.press(name, modifiers: mods) else { fail("unknown key \"\(name)\"") }
|
|
186
|
+
FileHandle.standardOutput.write(#"{"ok":true,"pressed":true}"#.data(using: .utf8)!)
|
|
187
|
+
|
|
188
|
+
case "click":
|
|
189
|
+
// Clicks where the cursor already is, so a caller must point first.
|
|
190
|
+
// Deliberately not folded into `point`: locating something should
|
|
191
|
+
// never be the same act as pressing it.
|
|
192
|
+
//
|
|
193
|
+
// Before pressing, LOOK. The accessibility tree reports what exists,
|
|
194
|
+
// not what is visible, so a control can be reported exactly where an
|
|
195
|
+
// overlay is covering it — this tool's own HUD did precisely that.
|
|
196
|
+
// The hit test resolves through the window server and sees what a
|
|
197
|
+
// click would really land on.
|
|
198
|
+
if !args.contains("--force"), let p = CGEvent(source: nil)?.location {
|
|
199
|
+
if let hit = Vision.hitTest(x: p.x, y: p.y),
|
|
200
|
+
let expected = flag("expect"), !expected.isEmpty {
|
|
201
|
+
let seen = "\(hit.role) \(hit.label)".lowercased()
|
|
202
|
+
if !seen.contains(expected.lowercased()) {
|
|
203
|
+
fail("refusing to click: \(hit.app) shows \(hit.role) \"\(hit.label)\" at the cursor, not \"\(expected)\" — something is covering it")
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Visual first: a press people can see leads the reaction slightly,
|
|
208
|
+
// the way a real one does.
|
|
209
|
+
if !args.contains("--quiet") { Pointer.clickFlourish() }
|
|
210
|
+
|
|
211
|
+
// Snapshot a patch around the cursor so the caller can be told whether
|
|
212
|
+
// anything actually happened, rather than inferring it from a
|
|
213
|
+
// successful call.
|
|
214
|
+
var beforeShot: CGImage?
|
|
215
|
+
var patch = CGRect.zero
|
|
216
|
+
if let p = CGEvent(source: nil)?.location {
|
|
217
|
+
patch = CGRect(x: p.x - 90, y: p.y - 60, width: 180, height: 120)
|
|
218
|
+
beforeShot = Vision.snapshot(patch)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
Typer.click(button: flag("button") ?? "left",
|
|
222
|
+
clicks: Int(flag("clicks") ?? "1") ?? 1,
|
|
223
|
+
modifiers: (flag("mod") ?? "").split(separator: "+").map(String.init))
|
|
224
|
+
|
|
225
|
+
// A UI needs a frame or two to react.
|
|
226
|
+
Thread.sleep(forTimeInterval: Double(flag("settle") ?? "") ?? 0.45)
|
|
227
|
+
var changed: Bool?
|
|
228
|
+
if beforeShot != nil, let diff = Vision.difference(patch, beforeShot) {
|
|
229
|
+
// 1.5% mean absolute difference over a 16x16 grid: enough to catch
|
|
230
|
+
// a button state or a menu opening, high enough to ignore a
|
|
231
|
+
// blinking caret.
|
|
232
|
+
changed = diff > 0.015
|
|
233
|
+
}
|
|
234
|
+
let payload: [String: Any] = [
|
|
235
|
+
"ok": true, "clicked": true,
|
|
236
|
+
"changed": changed as Any,
|
|
237
|
+
]
|
|
238
|
+
FileHandle.standardOutput.write(
|
|
239
|
+
(try? JSONSerialization.data(withJSONObject: payload)) ?? Data())
|
|
240
|
+
|
|
241
|
+
case "scroll":
|
|
242
|
+
Typer.scroll(dx: Int32(flag("dx") ?? "0") ?? 0,
|
|
243
|
+
dy: Int32(flag("dy") ?? "0") ?? 0,
|
|
244
|
+
steps: Int(flag("steps") ?? "10") ?? 10)
|
|
245
|
+
FileHandle.standardOutput.write(#"{"ok":true,"scrolled":true}"#.data(using: .utf8)!)
|
|
246
|
+
|
|
247
|
+
case "drag":
|
|
248
|
+
guard let tx = Double(flag("to-x") ?? ""), let ty = Double(flag("to-y") ?? "") else {
|
|
249
|
+
fail("drag needs --to-x --to-y (press starts wherever the cursor is)")
|
|
250
|
+
}
|
|
251
|
+
Typer.drag(to: CGPoint(x: tx, y: ty),
|
|
252
|
+
durationSeconds: Double(flag("duration") ?? "") ?? 0.6)
|
|
253
|
+
FileHandle.standardOutput.write(#"{"ok":true,"dragged":true}"#.data(using: .utf8)!)
|
|
254
|
+
|
|
255
|
+
case "hud":
|
|
256
|
+
// Long-lived: reads update lines on stdin until EOF.
|
|
257
|
+
HUD.run()
|
|
258
|
+
|
|
259
|
+
case "trusted":
|
|
260
|
+
let payload = ["ok": true, "trusted": AXTree.trusted()] as [String: Any]
|
|
261
|
+
FileHandle.standardOutput.write(try! JSONSerialization.data(withJSONObject: payload))
|
|
262
|
+
|
|
263
|
+
default:
|
|
264
|
+
fail("unknown verb \"\(verb)\" — expected read, screens, point, click, type, key, scroll, drag, ocr, capture, hittest, focused, hud or trusted")
|
|
265
|
+
}
|