acrylblends 1.0.4 → 1.0.6

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 (351) hide show
  1. package/docs/framework/agents/index.md +7 -5
  2. package/lib/accept-CNNHu4Cf.js +193 -0
  3. package/lib/{accepts-XBuZPVRI.js → accepts-Btq83xPd.js} +1 -1
  4. package/lib/accepts.js +1 -1
  5. package/lib/acryl-base.js +1 -331
  6. package/lib/adapter-CaZ6FZUU.js +37 -0
  7. package/lib/adapter.js +1 -36
  8. package/lib/{app-BM0rTCp7.js → app-CQcGWjrk.js} +4 -4
  9. package/lib/app-base-CpRMZL4v.js +332 -0
  10. package/lib/{audit-D5z9uwOP.d.ts → audit-GcLtvNLV.d.ts} +4 -2
  11. package/lib/{base-NSN_SNv-.js → base-D_6kfOr-.js} +1 -1
  12. package/lib/basic-auth-C0HTRpXh.js +20 -0
  13. package/lib/basic-auth-DFLflXNe.js +95 -0
  14. package/lib/basic-auth.js +1 -94
  15. package/lib/bearer-auth-Cwxt-vgS.js +94 -0
  16. package/lib/bearer-auth.js +1 -93
  17. package/lib/body-D7f4e7w_.js +94 -0
  18. package/lib/body-limit-WmIjWRLt.js +66 -0
  19. package/lib/body-limit.js +1 -65
  20. package/lib/buffer-CkEOJCcY.js +51 -0
  21. package/lib/bun.js +3 -3
  22. package/lib/cache-B0bHH7s5.js +169 -0
  23. package/lib/cache.js +1 -168
  24. package/lib/{client-DDLXwhBo.js → client-BrMFnMEd.js} +1 -1
  25. package/lib/{client-modules-CLvWOjbd.js → client-modules-drj4mjhj.js} +4 -4
  26. package/lib/client.js +1 -1
  27. package/lib/color-COa3DplO.js +37 -0
  28. package/lib/combine-TWuGdcbC.js +137 -0
  29. package/lib/combine.js +1 -136
  30. package/lib/{components-cEW3ovK0.js → components-B2ka9Ax1.js} +4 -4
  31. package/lib/{components-JXVm45kK.js → components-PVXwaOaC.js} +2 -2
  32. package/lib/compress-BzWqsHF1.js +79 -0
  33. package/lib/compress-C-9vK4B2.d.ts +11 -0
  34. package/lib/compress-DCyrrQ5D.js +11 -0
  35. package/lib/compress.d.ts +1 -1
  36. package/lib/compress.js +2 -78
  37. package/lib/concurrent-DrF3d2c-.js +30 -0
  38. package/lib/conninfo-BCROmdnf.d.ts +11 -0
  39. package/lib/conninfo-CWJwieh4.js +19 -0
  40. package/lib/constants-CBSEudrb.js +7 -0
  41. package/lib/{context-DDO0cfuP.js → context-CkOh7Ygv.js} +2 -2
  42. package/lib/context-storage-iuPoI_CR.js +52 -0
  43. package/lib/context-storage.js +1 -51
  44. package/lib/cookie-B1LlkzOe.js +125 -0
  45. package/lib/cookie-BHIduy2T.js +82 -0
  46. package/lib/cookie.js +1 -81
  47. package/lib/cordis/db-sqlite.d.ts +1 -1
  48. package/lib/cordis/door.d.ts +99 -5
  49. package/lib/cordis/door.js +562 -20
  50. package/lib/cordis/inspect.js +4 -4
  51. package/lib/cordis/kv-db.d.ts +1 -1
  52. package/lib/cordis/kv-db.js +1 -1
  53. package/lib/cordis/kv-json.d.ts +1 -1
  54. package/lib/cordis/kv-json.js +1 -1
  55. package/lib/cordis/node-server.d.ts +1 -1
  56. package/lib/cordis/node-server.js +3 -116
  57. package/lib/cordis/router-http.js +2 -2
  58. package/lib/cordis/router.js +4 -4
  59. package/lib/cordis/state.js +7 -7
  60. package/lib/{core-CaY8XDZ6.js → core-BOu1jYua.js} +1 -1
  61. package/lib/cors-CbAQ-8qU.js +106 -0
  62. package/lib/cors.js +1 -105
  63. package/lib/crypto-iVP-N-tS.js +34 -0
  64. package/lib/csrf-DqqOZtgy.js +101 -0
  65. package/lib/csrf.js +1 -100
  66. package/lib/css-C5CPVMvM.js +134 -0
  67. package/lib/css.js +2 -133
  68. package/lib/db/sqlite.d.ts +1 -25
  69. package/lib/dev-CLgu-JRe.js +50 -0
  70. package/lib/dev.js +1 -49
  71. package/lib/dom-YjND9Jt3.js +74 -0
  72. package/lib/door/host.d.ts +2 -2
  73. package/lib/door/host.js +1 -1
  74. package/lib/door/page.d.ts +3 -1
  75. package/lib/door/page.js +2 -2
  76. package/lib/door/protocol.d.ts +1 -1
  77. package/lib/door/protocol.js +1 -212
  78. package/lib/encode-BhbNLrjp.js +33 -0
  79. package/lib/factory-CLdSf-xS.js +30 -0
  80. package/lib/factory.js +1 -29
  81. package/lib/handler-C0wZ0KwO.js +12 -0
  82. package/lib/helpers-BEjcdbgw.js +5 -0
  83. package/lib/{hooks-w72iLeFR.js → hooks-CfC07ri0.js} +1 -1
  84. package/lib/html-CgNa89xw.js +31 -0
  85. package/lib/html-DGExBFNg.js +103 -0
  86. package/lib/html.d.ts +2 -5
  87. package/lib/html.js +2 -30
  88. package/lib/http-exception-MjWkT89V.js +54 -0
  89. package/lib/http-exception.js +1 -53
  90. package/lib/index-dAzQsJG9.d.ts +6 -0
  91. package/lib/index.js +3 -3
  92. package/lib/ip-restriction-jKtKCRM_.js +166 -0
  93. package/lib/ip-restriction.js +1 -165
  94. package/lib/ipaddr-DCoIo528.js +238 -0
  95. package/lib/jsx/dom/client.js +1 -1
  96. package/lib/jsx/dom/jsx-dev-runtime.js +1 -1
  97. package/lib/jsx/dom/jsx-runtime.js +2 -1
  98. package/lib/jsx/dom/server.js +3 -3
  99. package/lib/jsx/dom.js +5 -73
  100. package/lib/jsx/jsx-dev-runtime.d.ts +2 -5
  101. package/lib/jsx/jsx-dev-runtime.js +2 -13
  102. package/lib/jsx/jsx-runtime.d.ts +2 -2
  103. package/lib/jsx/jsx-runtime.js +4 -4
  104. package/lib/jsx/streaming.js +1 -1
  105. package/lib/jsx-CUQgttxU.js +47 -0
  106. package/lib/jsx-dev-runtime-B4WTyq8r.js +14 -0
  107. package/lib/{jsx-dev-runtime-CWLRA2F3.js → jsx-dev-runtime-BLfA-EhI.js} +2 -2
  108. package/lib/jsx-dev-runtime-CGt4kNYr.d.ts +6 -0
  109. package/lib/jsx-renderer-CfifM4hT.js +99 -0
  110. package/lib/jsx-renderer.js +1 -98
  111. package/lib/jsx-runtime-COCRWBJ9.js +2 -0
  112. package/lib/jsx.js +5 -46
  113. package/lib/{jwk-LkzeoaU8.js → jwk-CDPpYQpD.js} +3 -3
  114. package/lib/jwk.js +1 -1
  115. package/lib/{jwt-BGJBw5AF.js → jwt-BXoIA2fQ.js} +2 -2
  116. package/lib/{jwt-CskdhEW9.js → jwt-lyS4Q3mT.js} +3 -3
  117. package/lib/jwt.js +2 -2
  118. package/lib/kv/db.d.ts +1 -10
  119. package/lib/kv/db.js +1 -56
  120. package/lib/kv/json.d.ts +1 -9
  121. package/lib/kv/json.js +1 -223
  122. package/lib/{language-D9a4zqvn.js → language-DeGRtLJt.js} +2 -2
  123. package/lib/language.js +1 -1
  124. package/lib/{linear-router-C3FPib6h.js → linear-router-CmZn_JLV.js} +2 -2
  125. package/lib/logger-Ckxu8diz.js +54 -0
  126. package/lib/logger.js +1 -53
  127. package/lib/loopback-only-BbasoF7R.js +90 -0
  128. package/lib/loopback-only.js +1 -89
  129. package/lib/method-not-allowed-TO2_hnfl.js +82 -0
  130. package/lib/method-not-allowed.js +1 -81
  131. package/lib/method-override-DnZKfJbV.js +103 -0
  132. package/lib/method-override.js +1 -102
  133. package/lib/middleware-BfG_fp8P.js +12 -0
  134. package/lib/mime-BnScXkQn.js +77 -0
  135. package/lib/mount-QkK_3NtJ.js +67 -0
  136. package/lib/mount.js +1 -66
  137. package/lib/node-server/conninfo.d.ts +1 -10
  138. package/lib/node-server/conninfo.js +1 -18
  139. package/lib/node-server/register.js +25 -2
  140. package/lib/node-server/serve-static.d.ts +1 -17
  141. package/lib/node-server/serve-static.js +1 -1
  142. package/lib/node-server.d.ts +2 -2
  143. package/lib/node-server.js +4 -4
  144. package/lib/open-BqK4D2Wn.d.ts +11 -0
  145. package/lib/open-But9E2rL.js +57 -0
  146. package/lib/open-CULVJgOc.d.ts +12 -0
  147. package/lib/open-Dp4sxspc.js +224 -0
  148. package/lib/open-T7kgbO2O.d.ts +27 -0
  149. package/lib/{pattern-router-HZxjDGEQ.js → pattern-router-CJBecJBd.js} +1 -1
  150. package/lib/powered-by-CXQNjNIX.js +24 -0
  151. package/lib/powered-by.js +1 -23
  152. package/lib/pretty-json-CeC1yvYt.js +39 -0
  153. package/lib/pretty-json.js +1 -38
  154. package/lib/{protocol-C37gPePD.d.ts → protocol-CR31M7K_.d.ts} +2 -0
  155. package/lib/protocol-Dt0AZBOz.js +213 -0
  156. package/lib/proxy-C2w_mH7k.js +121 -0
  157. package/lib/proxy.js +1 -120
  158. package/lib/quick.js +4 -4
  159. package/lib/{reg-exp-router-DC1R3qoY.js → reg-exp-router-C_WV84eV.js} +2 -2
  160. package/lib/request-DvMgGtlW.js +323 -0
  161. package/lib/request.js +1 -322
  162. package/lib/route-D8m-a2I1.js +126 -0
  163. package/lib/route.js +1 -125
  164. package/lib/router/linear-router.js +1 -1
  165. package/lib/router/pattern-router.js +1 -1
  166. package/lib/router/reg-exp-router.js +1 -1
  167. package/lib/router/smart-router.js +1 -1
  168. package/lib/router/trie-router.js +1 -1
  169. package/lib/router-Be_o6nFu.js +35 -0
  170. package/lib/router.js +1 -34
  171. package/lib/schema-validator-EK3cUUCt.js +22 -0
  172. package/lib/schema-validator.js +1 -21
  173. package/lib/{secure-headers-mP3j6Hm6.js → secure-headers-C5ZOcXqk.js} +1 -1
  174. package/lib/secure-headers.js +1 -1
  175. package/lib/serve-static-B7sqnftW.d.ts +18 -0
  176. package/lib/{serve-static-3uo_9rjF.js → serve-static-C1Nkj_xB.js} +2 -2
  177. package/lib/{serve-static-Cp5adzGo.js → serve-static-EiJf1zkO.js} +4 -4
  178. package/lib/serve-static.js +1 -1
  179. package/lib/{smart-router-C5CZ5XSM.js → smart-router-BjxkDQCE.js} +1 -1
  180. package/lib/{snapshot-text-BLz8nlSc.js → snapshot-text-CtQm3n8n.js} +23 -14
  181. package/lib/{src-dy3Fk9tA.js → src-BTBm4OPD.js} +6 -6
  182. package/lib/src-LX0E4ec4.js +117 -0
  183. package/lib/{ssg-BwjcA-Ge.js → ssg-BEl7MHqg.js} +5 -5
  184. package/lib/ssg.js +1 -1
  185. package/lib/stream-CzM_SHkq.js +88 -0
  186. package/lib/{streaming-DHST3Scg.js → streaming-BlDaph8U.js} +3 -3
  187. package/lib/streaming.js +1 -1
  188. package/lib/{test-websocket-D8WWqE5G.js → test-websocket-BLeEvybj.js} +2 -2
  189. package/lib/testing-BQx5T_UX.js +17 -0
  190. package/lib/testing.js +1 -16
  191. package/lib/timeout-FeqZ9A-0.js +42 -0
  192. package/lib/timeout.js +1 -41
  193. package/lib/tiny.js +2 -2
  194. package/lib/trailing-slash-DwTx-1sk.js +85 -0
  195. package/lib/trailing-slash.js +1 -84
  196. package/lib/{trie-router-CfFbapzL.js → trie-router-JydLKUa2.js} +2 -2
  197. package/lib/url-BPh4d6j-.js +197 -0
  198. package/lib/utils/accept.js +1 -192
  199. package/lib/utils/basic-auth.js +1 -19
  200. package/lib/utils/body.js +1 -93
  201. package/lib/utils/buffer.js +1 -50
  202. package/lib/utils/color.js +1 -36
  203. package/lib/utils/compress.d.ts +1 -10
  204. package/lib/utils/compress.js +1 -10
  205. package/lib/utils/concurrent.js +1 -29
  206. package/lib/utils/constants.js +1 -6
  207. package/lib/utils/cookie.js +1 -124
  208. package/lib/utils/crypto.js +1 -33
  209. package/lib/utils/encode.js +1 -32
  210. package/lib/utils/handler.js +1 -11
  211. package/lib/utils/html.js +1 -102
  212. package/lib/utils/ipaddr.js +1 -237
  213. package/lib/utils/jwt.js +1 -1
  214. package/lib/utils/mime.js +1 -76
  215. package/lib/utils/stream.js +1 -87
  216. package/lib/utils/url.js +1 -196
  217. package/lib/{validator-D84mq-dO.js → validator-Btiyt5VJ.js} +3 -3
  218. package/lib/validator.js +1 -1
  219. package/lib/websocket-DsyBIKVA.js +56 -0
  220. package/lib/ws.js +1 -55
  221. package/package.json +1 -1
  222. package/plugins/acryl-door/src/cli/acryl.ts +36 -5
  223. package/plugins/acryl-door/src/host/channel.ts +14 -6
  224. package/plugins/acryl-door/src/host/chat-tools.ts +2 -0
  225. package/plugins/acryl-door/src/host/door.ts +97 -12
  226. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  227. package/plugins/acryl-door/src/host/shell.ts +147 -0
  228. package/plugins/acryl-door/src/host/shim.ts +39 -3
  229. package/plugins/acryl-door/src/host/verbs.ts +39 -2
  230. package/plugins/acryl-door/src/index.ts +22 -3
  231. package/plugins/acryl-door/src/page/channel.ts +3 -1
  232. package/plugins/acryl-door/src/page/index.ts +2 -1
  233. package/plugins/acryl-door/src/page/protocol.ts +2 -0
  234. package/plugins/acryl-door/src/shell/protocol.ts +130 -0
  235. package/plugins/acryl-node-server/src/register.ts +29 -2
  236. package/plugins/agent/model.ts +6 -1
  237. package/plugins/agent/tools.ts +12 -2
  238. package/plugins/agent-panel/client/describe.ts +44 -0
  239. package/plugins/agent-panel/client/main.ts +4 -1
  240. package/plugins/agent-panel/headless.ts +29 -8
  241. package/plugins/agent-panel/index.ts +22 -10
  242. package/plugins/agent-panel/launch.ts +67 -0
  243. package/plugins/desktop/attach.ts +72 -0
  244. package/plugins/desktop/door-shell.ts +379 -0
  245. package/plugins/desktop/main.ts +82 -15
  246. package/plugins/extensions/index.ts +3 -4
  247. package/plugins/extensions/stage.ts +33 -5
  248. package/plugins/openai/index.ts +1 -1
  249. package/templates/agent/AGENTS.md +4 -0
  250. package/templates/agent/blend.yaml +4 -1
  251. package/templates/agent/src/app.ts +2 -0
  252. package/templates/agent/src/routes.ts +1 -1
  253. package/templates/agent/template.json +1 -0
  254. package/templates/agent/test/builder.test.ts +26 -21
  255. package/lib/helpers-CvTauQT5.js +0 -5
  256. package/lib/middleware-CPQ8himN.js +0 -12
  257. package/plugins/acryl-router/src/connection.d.ts +0 -25
  258. package/plugins/acryl-router/src/core/app-base.d.ts +0 -215
  259. package/plugins/acryl-router/src/core/app.d.ts +0 -19
  260. package/plugins/acryl-router/src/core/client/client.d.ts +0 -4
  261. package/plugins/acryl-router/src/core/client/fetch-result-please.d.ts +0 -35
  262. package/plugins/acryl-router/src/core/client/index.d.ts +0 -7
  263. package/plugins/acryl-router/src/core/client/types.d.ts +0 -229
  264. package/plugins/acryl-router/src/core/client/utils.d.ts +0 -18
  265. package/plugins/acryl-router/src/core/compose.d.ts +0 -14
  266. package/plugins/acryl-router/src/core/context.d.ts +0 -443
  267. package/plugins/acryl-router/src/core/helper/accepts/accepts.d.ts +0 -30
  268. package/plugins/acryl-router/src/core/helper/accepts/index.d.ts +0 -5
  269. package/plugins/acryl-router/src/core/helper/conninfo/index.d.ts +0 -5
  270. package/plugins/acryl-router/src/core/helper/conninfo/types.d.ts +0 -36
  271. package/plugins/acryl-router/src/core/helper/cookie/index.d.ts +0 -25
  272. package/plugins/acryl-router/src/core/helper/css/common.d.ts +0 -59
  273. package/plugins/acryl-router/src/core/helper/css/index.d.ts +0 -80
  274. package/plugins/acryl-router/src/core/helper/dev/index.d.ts +0 -20
  275. package/plugins/acryl-router/src/core/helper/factory/index.d.ts +0 -82
  276. package/plugins/acryl-router/src/core/helper/html/index.d.ts +0 -8
  277. package/plugins/acryl-router/src/core/helper/proxy/index.d.ts +0 -73
  278. package/plugins/acryl-router/src/core/helper/route/index.d.ts +0 -75
  279. package/plugins/acryl-router/src/core/helper/ssg/index.d.ts +0 -7
  280. package/plugins/acryl-router/src/core/helper/ssg/middleware.d.ts +0 -44
  281. package/plugins/acryl-router/src/core/helper/ssg/plugins.d.ts +0 -27
  282. package/plugins/acryl-router/src/core/helper/ssg/ssg.d.ts +0 -91
  283. package/plugins/acryl-router/src/core/helper/ssg/utils.d.ts +0 -16
  284. package/plugins/acryl-router/src/core/helper/streaming/index.d.ts +0 -8
  285. package/plugins/acryl-router/src/core/helper/streaming/sse.d.ts +0 -13
  286. package/plugins/acryl-router/src/core/helper/streaming/stream.d.ts +0 -3
  287. package/plugins/acryl-router/src/core/helper/streaming/text.d.ts +0 -3
  288. package/plugins/acryl-router/src/core/helper/streaming/utils.d.ts +0 -1
  289. package/plugins/acryl-router/src/core/helper/testing/index.d.ts +0 -13
  290. package/plugins/acryl-router/src/core/helper/websocket/index.d.ts +0 -70
  291. package/plugins/acryl-router/src/core/http-exception.d.ts +0 -58
  292. package/plugins/acryl-router/src/core/index.d.ts +0 -37
  293. package/plugins/acryl-router/src/core/jsx/base.d.ts +0 -61
  294. package/plugins/acryl-router/src/core/jsx/children.d.ts +0 -9
  295. package/plugins/acryl-router/src/core/jsx/components.d.ts +0 -15
  296. package/plugins/acryl-router/src/core/jsx/constants.d.ts +0 -6
  297. package/plugins/acryl-router/src/core/jsx/context.d.ts +0 -51
  298. package/plugins/acryl-router/src/core/jsx/dom/components.d.ts +0 -10
  299. package/plugins/acryl-router/src/core/jsx/dom/context.d.ts +0 -3
  300. package/plugins/acryl-router/src/core/jsx/dom/css.d.ts +0 -83
  301. package/plugins/acryl-router/src/core/jsx/dom/hooks/index.d.ts +0 -39
  302. package/plugins/acryl-router/src/core/jsx/dom/intrinsic-element/components.d.ts +0 -16
  303. package/plugins/acryl-router/src/core/jsx/dom/jsx-dev-runtime.d.ts +0 -8
  304. package/plugins/acryl-router/src/core/jsx/dom/jsx-runtime.d.ts +0 -7
  305. package/plugins/acryl-router/src/core/jsx/dom/render.d.ts +0 -73
  306. package/plugins/acryl-router/src/core/jsx/dom/utils.d.ts +0 -1
  307. package/plugins/acryl-router/src/core/jsx/hooks/index.d.ts +0 -47
  308. package/plugins/acryl-router/src/core/jsx/index.d.ts +0 -76
  309. package/plugins/acryl-router/src/core/jsx/intrinsic-element/common.d.ts +0 -6
  310. package/plugins/acryl-router/src/core/jsx/intrinsic-element/components.d.ts +0 -13
  311. package/plugins/acryl-router/src/core/jsx/intrinsic-elements.d.ts +0 -735
  312. package/plugins/acryl-router/src/core/jsx/streaming.d.ts +0 -36
  313. package/plugins/acryl-router/src/core/jsx/types.d.ts +0 -36
  314. package/plugins/acryl-router/src/core/jsx/utils.d.ts +0 -4
  315. package/plugins/acryl-router/src/core/request/constants.d.ts +0 -1
  316. package/plugins/acryl-router/src/core/request.d.ts +0 -306
  317. package/plugins/acryl-router/src/core/router/reg-exp-router/index.d.ts +0 -6
  318. package/plugins/acryl-router/src/core/router/reg-exp-router/matcher.d.ts +0 -7
  319. package/plugins/acryl-router/src/core/router/reg-exp-router/node.d.ts +0 -13
  320. package/plugins/acryl-router/src/core/router/reg-exp-router/prepared-router.d.ts +0 -17
  321. package/plugins/acryl-router/src/core/router/reg-exp-router/router.d.ts +0 -11
  322. package/plugins/acryl-router/src/core/router/reg-exp-router/trie.d.ts +0 -8
  323. package/plugins/acryl-router/src/core/router/smart-router/index.d.ts +0 -5
  324. package/plugins/acryl-router/src/core/router/smart-router/router.d.ts +0 -11
  325. package/plugins/acryl-router/src/core/router/trie-router/index.d.ts +0 -5
  326. package/plugins/acryl-router/src/core/router/trie-router/node.d.ts +0 -6
  327. package/plugins/acryl-router/src/core/router/trie-router/router.d.ts +0 -7
  328. package/plugins/acryl-router/src/core/router/utils.d.ts +0 -1
  329. package/plugins/acryl-router/src/core/router.d.ts +0 -97
  330. package/plugins/acryl-router/src/core/types.d.ts +0 -573
  331. package/plugins/acryl-router/src/core/utils/accept.d.ts +0 -6
  332. package/plugins/acryl-router/src/core/utils/body.d.ts +0 -79
  333. package/plugins/acryl-router/src/core/utils/buffer.d.ts +0 -17
  334. package/plugins/acryl-router/src/core/utils/color.d.ts +0 -21
  335. package/plugins/acryl-router/src/core/utils/concurrent.d.ts +0 -11
  336. package/plugins/acryl-router/src/core/utils/constants.d.ts +0 -4
  337. package/plugins/acryl-router/src/core/utils/cookie.d.ts +0 -41
  338. package/plugins/acryl-router/src/core/utils/crypto.d.ts +0 -15
  339. package/plugins/acryl-router/src/core/utils/handler.d.ts +0 -6
  340. package/plugins/acryl-router/src/core/utils/headers.d.ts +0 -8
  341. package/plugins/acryl-router/src/core/utils/html.d.ts +0 -44
  342. package/plugins/acryl-router/src/core/utils/http-status.d.ts +0 -32
  343. package/plugins/acryl-router/src/core/utils/mime.d.ts +0 -70
  344. package/plugins/acryl-router/src/core/utils/stream.d.ts +0 -31
  345. package/plugins/acryl-router/src/core/utils/types.d.ts +0 -128
  346. package/plugins/acryl-router/src/core/utils/url.d.ts +0 -44
  347. package/plugins/acryl-router/src/core/validator/index.d.ts +0 -7
  348. package/plugins/acryl-router/src/core/validator/utils.d.ts +0 -29
  349. package/plugins/acryl-router/src/core/validator/validator.d.ts +0 -16
  350. package/plugins/acryl-router/src/helpers/index.d.ts +0 -17
  351. package/plugins/acryl-router/src/helpers/schema-validator.d.ts +0 -54
package/lib/ws.js CHANGED
@@ -1,56 +1,2 @@
1
- //#region ../../plugins/acryl-router/src/core/helper/websocket/index.ts
2
- /**
3
- * A context for controlling WebSockets
4
- */
5
- var WSContext = class {
6
- #init;
7
- constructor(init) {
8
- this.#init = init;
9
- this.raw = init.raw;
10
- this.url = init.url ? new URL(init.url) : null;
11
- this.protocol = init.protocol ?? null;
12
- }
13
- send(source, options) {
14
- this.#init.send(source, options ?? {});
15
- }
16
- raw;
17
- binaryType = "arraybuffer";
18
- get readyState() {
19
- return this.#init.readyState;
20
- }
21
- get bufferedAmount() {
22
- return this.#init.bufferedAmount ?? 0;
23
- }
24
- url;
25
- protocol;
26
- close(code, reason) {
27
- this.#init.close(code, reason);
28
- }
29
- };
30
- const createWSMessageEvent = (source) => {
31
- return new MessageEvent("message", { data: source });
32
- };
33
- /**
34
- * Create a WebSocket adapter/helper
35
- */
36
- const defineWebSocketHelper = (handler) => {
37
- return ((...args) => {
38
- if (typeof args[0] === "function") {
39
- const [createEvents, options] = args;
40
- return async function upgradeWebSocket(c, next) {
41
- const result = await handler(c, await createEvents(c), options);
42
- if (result) return result;
43
- await next();
44
- };
45
- } else {
46
- const [c, events, options] = args;
47
- return (async () => {
48
- const upgraded = await handler(c, events, options);
49
- if (!upgraded) throw new Error("Failed to upgrade WebSocket");
50
- return upgraded;
51
- })();
52
- }
53
- });
54
- };
55
- //#endregion
1
+ import { n as createWSMessageEvent, r as defineWebSocketHelper, t as WSContext } from "./websocket-DsyBIKVA.js";
56
2
  export { WSContext, createWSMessageEvent, defineWebSocketHelper };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,7 +4,7 @@
4
4
  * `.acryl/door.json` upward, or `--dir`), never through PATH or a machine-wide registry, so it reaches only the app running from that
5
5
  * folder. The secret is read from that file and sent as a `Bearer` header; it is never printed.
6
6
  *
7
- * acryl brief | look | act <op> ... | plugin list|add|remove|reload | tool list|call | verbs (--json for the raw value)
7
+ * acryl brief | look | act <op> ... | plugin list|add|remove|reload | tool list|call | window list|open|focus|close|resize | menu list|click | verbs (--json for the raw value)
8
8
  * acryl control <...> the same, the ACRYL Host form (`control snapshot`, `control click`, ... are look and act)
9
9
  *
10
10
  * Exit codes: 0 done, 1 the verb failed (its code and message are printed), 2 a usage error, 3 no running app in this folder.
@@ -16,8 +16,8 @@ import { fileURLToPath } from 'node:url'
16
16
  import { readDoorFile, STATE_DIR, DOOR_FILE } from '../host/door-file.ts'
17
17
 
18
18
  const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'])
19
- const VALUED = new Set(['--dir', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--tool', '--args', '--path'])
20
- const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--help'])
19
+ const VALUED = new Set(['--dir', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path'])
20
+ const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--force', '--help'])
21
21
 
22
22
  export class UsageError extends Error {}
23
23
 
@@ -30,7 +30,7 @@ export interface Invocation {
30
30
 
31
31
  const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
32
32
  brief
33
- look [--cursor <n>] [--max-nodes <n>]
33
+ look [--cursor <n>] [--max-nodes <n>] [--window <n>]
34
34
  act click --ref <r>
35
35
  act type --ref <r> --text <t> [--submit] [--no-clear]
36
36
  act select --ref <r> --option <o>
@@ -39,7 +39,11 @@ const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
39
39
  act reload
40
40
  act navigate --path <path of the app, e.g. /demo>
41
41
  act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
42
+ (look and every act take --window <n>: the desktop window whose page it is for; window list prints the numbers)
42
43
  plugin list | plugin add <folder> | plugin remove <name> | plugin reload
44
+ window list | window open [--path <p>] | window focus --id <n> | window close --id <n> [--force]
45
+ window resize --id <n> --width <px> --height <px>
46
+ menu list | menu click --path "File>New Note" [--force]
43
47
  tool list | tool call --tool <name> [--args '<json>']
44
48
  verbs`
45
49
 
@@ -82,12 +86,14 @@ export function parseInvocation(argv: readonly string[]): Invocation {
82
86
  case 'look': {
83
87
  const cursor = integer(values, '--cursor')
84
88
  const maxNodes = integer(values, '--max-nodes')
85
- return make('look', { ...(cursor === undefined ? {} : { cursor }), ...(maxNodes === undefined ? {} : { maxNodes }) })
89
+ const window = integer(values, '--window')
90
+ return make('look', { ...(cursor === undefined ? {} : { cursor }), ...(maxNodes === undefined ? {} : { maxNodes }), ...(window === undefined ? {} : { window }) })
86
91
  }
87
92
  case 'act': {
88
93
  if (second === undefined || !ACT_OPS.has(second)) throw new UsageError(`act needs one of ${[...ACT_OPS].join(', ')}\n${USAGE}`)
89
94
  const amount = integer(values, '--amount')
90
95
  const timeoutMs = integer(values, '--timeout-ms')
96
+ const window = integer(values, '--window')
91
97
  const pick = (flag: string, key: string): Record<string, string> => (values.has(flag) ? { [key]: values.get(flag) as string } : {})
92
98
  return make('act', {
93
99
  op: second,
@@ -95,6 +101,7 @@ export function parseInvocation(argv: readonly string[]): Invocation {
95
101
  ...pick('--direction', 'direction'), ...pick('--role', 'role'), ...pick('--name', 'name'), ...pick('--path', 'path'),
96
102
  ...(amount === undefined ? {} : { amount }),
97
103
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
104
+ ...(window === undefined ? {} : { window }),
98
105
  ...(set.has('--submit') ? { submit: true } : {}),
99
106
  ...(set.has('--no-clear') ? { clear: false } : {}),
100
107
  ...(set.has('--gone') ? { gone: true } : {}),
@@ -106,6 +113,30 @@ export function parseInvocation(argv: readonly string[]): Invocation {
106
113
  if (second === 'remove') { if (third === undefined) throw new UsageError('plugin remove needs a plugin name'); return make('plugin-remove', { name: third }) }
107
114
  throw new UsageError(`plugin needs list, add, remove or reload\n${USAGE}`)
108
115
  }
116
+ case 'window': {
117
+ const id = integer(values, '--id')
118
+ if (second === 'list') return make('window-list')
119
+ if (second === 'open') return make('window-open', values.has('--path') ? { path: values.get('--path') } : {})
120
+ if (second === 'focus' || second === 'close' || second === 'resize') {
121
+ if (id === undefined) throw new UsageError(`window ${second} needs --id <n> (window list prints the ids)`)
122
+ if (second === 'focus') return make('window-focus', { id })
123
+ if (second === 'close') return make('window-close', { id, ...(set.has('--force') ? { force: true } : {}) })
124
+ const width = integer(values, '--width')
125
+ const height = integer(values, '--height')
126
+ if (width === undefined || height === undefined) throw new UsageError('window resize needs --width <px> and --height <px>')
127
+ return make('window-resize', { id, width, height })
128
+ }
129
+ throw new UsageError(`window needs list, open, focus, close or resize\n${USAGE}`)
130
+ }
131
+ case 'menu': {
132
+ if (second === 'list') return make('menu-list')
133
+ if (second === 'click') {
134
+ const path = values.get('--path')
135
+ if (path === undefined) throw new UsageError('menu click needs --path "File>New Note" (menu list prints the paths)')
136
+ return make('menu-click', { path, ...(set.has('--force') ? { force: true } : {}) })
137
+ }
138
+ throw new UsageError(`menu needs list or click\n${USAGE}`)
139
+ }
109
140
  case 'tool': {
110
141
  if (second === 'list') return make('tool-list')
111
142
  if (second === 'call') {
@@ -31,6 +31,8 @@ interface Pending {
31
31
  interface Connection {
32
32
  readonly stream: PageStream
33
33
  readonly windowId: string
34
+ /** The desktop window the page is in, when the Electron shell tagged it. */
35
+ readonly shellWindow: number | undefined
34
36
  focused: boolean
35
37
  /** Order of last focus (or connection), so the most recent wins. */
36
38
  lastActive: number
@@ -52,10 +54,10 @@ export class UiChannel {
52
54
  * Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
53
55
  * @returns the function to call when this stream ends.
54
56
  */
55
- attach(windowId: string, focused: boolean, stream: PageStream): () => void {
57
+ attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number): () => void {
56
58
  const previous = this.connections.get(windowId)
57
59
  if (previous !== undefined) this.drop(previous, true)
58
- const connection: Connection = { stream, windowId, focused, lastActive: ++this.clock }
60
+ const connection: Connection = { stream, windowId, shellWindow, focused, lastActive: ++this.clock }
59
61
  this.connections.set(windowId, connection)
60
62
  for (const waiter of [...this.attachWaiters]) waiter()
61
63
  return () => { if (this.connections.get(windowId) === connection) this.drop(connection, false) }
@@ -82,17 +84,23 @@ export class UiChannel {
82
84
  }
83
85
 
84
86
  /** The windows currently connected, most recently active first (for diagnostics and tests). */
85
- windows(): readonly { windowId: string; focused: boolean }[] {
86
- return this.byRecency().map(({ windowId, focused }) => ({ windowId, focused }))
87
+ windows(): readonly { windowId: string; focused: boolean; shellWindow?: number }[] {
88
+ return this.byRecency().map(({ windowId, focused, shellWindow }) => ({ windowId, focused, ...(shellWindow === undefined ? {} : { shellWindow }) }))
87
89
  }
88
90
 
89
91
  /**
90
92
  * Ask the current page to do something.
91
93
  * @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
92
94
  */
93
- call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<UiResult> {
95
+ call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}): Promise<UiResult> {
94
96
  if (this.closed) return Promise.reject(new UiControlError('unloaded', 'the door was unloaded'))
95
- const target = this.byRecency()[0]
97
+ const ordered = this.byRecency()
98
+ // `window` is the number `window list` prints: that desktop window's page, not the one used last.
99
+ const target = options.window === undefined ? ordered[0] : ordered.find(connection => connection.shellWindow === options.window)
100
+ if (target === undefined && options.window !== undefined) {
101
+ const known = ordered.flatMap(connection => (connection.shellWindow === undefined ? [] : [String(connection.shellWindow)]))
102
+ return Promise.reject(new UiControlError('no-window', `no page of window ${String(options.window)} is connected to the door${known.length === 0 ? ' (no desktop window has a page connected)' : `; windows with a page: ${known.join(', ')}`}; window list prints the windows`))
103
+ }
96
104
  if (target === undefined) return Promise.reject(new UiControlError('no-window', 'no page of the app is open to control'))
97
105
  const signal = options.signal
98
106
  if (signal?.aborted === true) return Promise.reject(new UiControlError('aborted', 'the call was cancelled'))
@@ -20,6 +20,8 @@ export function registerChatTools(tools: ToolsPort, door: Door): () => void {
20
20
  for (const verb of VERBS) {
21
21
  removers.push(tools.register(chatToolName(verb), {
22
22
  description: VERB_SPECS[verb].description,
23
+ // The model is told which fields the verb takes (an `act` needs its `op`), the same schema MCP lists.
24
+ parameters: VERB_SPECS[verb].arguments as unknown as Readonly<Record<string, unknown>>,
23
25
  run: async (input: unknown) => {
24
26
  try {
25
27
  return (await door.run(verb, input ?? {}, running.signal)).text
@@ -9,10 +9,12 @@ import { isAbsolute, relative, resolve } from 'node:path'
9
9
  import { parseUiRequest, UiControlError, type AuditEntry, type UiActionResult, type UiRequest, type UiResult, type UiSnapshot } from '../page/protocol.ts'
10
10
  import { renderSnapshot } from '../page/snapshot-text.ts'
11
11
  import type { AuditLog } from './audit.ts'
12
+ import { ShellError, parseShellRequest, type ShellErrorCode, type ShellOp, type ShellRequest, type ShellResult } from '../shell/protocol.ts'
12
13
  import type { UiChannel } from './channel.ts'
14
+ import { NO_DESKTOP, type ShellChannel } from './shell.ts'
13
15
  import { VERB_SPECS, VERBS } from './verbs.ts'
14
16
 
15
- export type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed'
17
+ export type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed' | ShellErrorCode
16
18
 
17
19
  export class DoorError extends Error {
18
20
  readonly code: DoorErrorCode
@@ -35,13 +37,15 @@ export interface ExtensionsPort {
35
37
  /** The slice of acrylblends' `tools` registry (the `agent` plugin's) the tool verbs use, and where the built-in chat finds the verbs. */
36
38
  export interface ToolsPort {
37
39
  /** @returns the function that removes the tool. @throws when the name is taken. */
38
- register(name: string, tool: { readonly description: string; run(input: unknown): unknown }): () => void
40
+ register(name: string, tool: { readonly description: string; readonly parameters?: Readonly<Record<string, unknown>>; run(input: unknown): unknown }): () => void
39
41
  list(): ReadonlyArray<{ readonly name: string; readonly description: string }>
40
42
  call(name: string, input: unknown): Promise<unknown>
41
43
  }
42
44
 
43
45
  export interface DoorDeps {
44
46
  readonly channel: UiChannel
47
+ /** The desktop shell (the Electron main process of `npm run desktop`), when the app can have one. */
48
+ readonly shell?: ShellChannel
45
49
  readonly audit: AuditLog
46
50
  /** The app's name, folder and, when it listens, its address (what `brief` prints so an agent can curl the running app). */
47
51
  readonly app: { readonly name: string; readonly root: string; readonly url?: string | undefined }
@@ -62,6 +66,17 @@ export interface VerbAnswer {
62
66
  readonly text: string
63
67
  }
64
68
 
69
+ /** A shell answer as text an agent reads: windows and menu items one to a line. */
70
+ function renderShell(result: ShellResult): string {
71
+ if ('windows' in result) {
72
+ return result.windows.length === 0
73
+ ? 'no windows are open'
74
+ : result.windows.map(window => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.focused ? ' focused' : ''}`).join('\n')
75
+ }
76
+ if ('items' in result) return result.items.length === 0 ? 'the app has no menu' : result.items.map(item => `${item.path}${item.enabled ? '' : ' (disabled)'}${item.ends === true ? ' (ends the app; needs force)' : ''}`).join('\n')
77
+ return result.detail
78
+ }
79
+
65
80
  /** The files an agent should read first, when the app has them. */
66
81
  const SELF_KNOWLEDGE = ['AGENTS.md', 'CLAUDE.md', '.agents/skills', '.claude/skills', 'docs', 'plugins']
67
82
 
@@ -101,6 +116,8 @@ export class Door {
101
116
  return { value: offered, text: offered.length === 0 ? 'no app tools are offered through the door (tools.expose is empty)' : offered.map(tool => `${tool.name}: ${tool.description}`).join('\n') }
102
117
  }
103
118
  case 'tool-call': return this.callTool(input)
119
+ case 'window-list': case 'window-open': case 'window-focus': case 'window-close': case 'window-resize': case 'menu-list': case 'menu-click':
120
+ return this.desktop(verb, input, signal)
104
121
  default: throw new DoorError('unknown-verb', `unknown verb ${JSON.stringify(verb)}; the verbs are ${VERBS.join(', ')}`)
105
122
  }
106
123
  }
@@ -130,25 +147,93 @@ export class Door {
130
147
  }
131
148
 
132
149
  look(input: Record<string, unknown>, signal: AbortSignal): Promise<UiSnapshot> {
133
- const request = this.parse({ ...input, op: 'snapshot' })
134
- return this.callPage('look', request, signal) as Promise<UiSnapshot>
150
+ const { window, rest } = this.windowOf(input)
151
+ const request = this.parse({ ...rest, op: 'snapshot' })
152
+ return this.callPage('look', request, signal, window) as Promise<UiSnapshot>
135
153
  }
136
154
 
137
155
  async act(input: Record<string, unknown>, signal: AbortSignal): Promise<UiActionResult> {
138
156
  if (input.op === 'snapshot') throw new DoorError('invalid', 'use look to see the page')
139
- const request = this.parse(input)
140
- if (request.op !== 'reload' && request.op !== 'navigate') return await this.callPage(`act ${String(input.op)}`, request, signal) as UiActionResult
157
+ const { window, rest } = this.windowOf(input)
158
+ const request = this.parse(rest)
159
+ if (request.op !== 'reload' && request.op !== 'navigate') return await this.callPage(`act ${String(input.op)}`, request, signal, window) as UiActionResult
141
160
  // The page is replaced: wait for the new one to connect, so the next look sees it. The page may drop before it has answered; that is the reload working.
142
161
  const back = this.deps.channel.expectAttach(this.deps.pageBackMs ?? PAGE_BACK_MS)
143
162
  let answer: UiActionResult
144
163
  try {
145
- answer = await this.callPage(`act ${request.op}`, request, signal) as UiActionResult
164
+ answer = await this.callPage(`act ${request.op}`, request, signal, window) as UiActionResult
146
165
  } catch (cause) {
147
166
  if (!(cause instanceof UiControlError && cause.code === 'disconnected')) { void back; throw cause }
148
167
  answer = { ok: true, detail: request.op === 'reload' ? 'reloading' : `going to ${request.path}` }
149
168
  }
150
169
  if (!await back) throw new UiControlError('timeout', request.op === 'reload' ? 'the page did not come back after the reload' : `the page did not come back after going to ${request.path}: is there a page at that path, and does it load the door's script?`)
151
- return { ...answer, detail: `${answer.detail ?? 'done'}; the page is back: look again` }
170
+ const landed = request.op === 'navigate' ? await this.statusAt(request.path) : undefined
171
+ return { ...answer, detail: `${answer.detail ?? 'done'}${landed === undefined ? '' : `; ${landed}`}; the page is back: look again` }
172
+ }
173
+
174
+ /**
175
+ * What the app answers at a path the page was sent to, so an agent that mistyped one is told ("404 at /nope") and not left to find out from a page it did not expect.
176
+ * A second GET of the same path (a page is read-only); nothing when the app has no address or does not answer in time.
177
+ */
178
+ private async statusAt(path: string): Promise<string | undefined> {
179
+ const base = this.deps.app.url
180
+ if (base === undefined) return undefined
181
+ try {
182
+ const response = await fetch(new URL(path, base), { headers: { accept: 'text/html' }, signal: AbortSignal.timeout(3000) })
183
+ await response.body?.cancel()
184
+ return response.ok ? `${response.status} at ${path}` : `${response.status} at ${path}: the app serves no page there, look at what is shown and navigate to a path it does`
185
+ } catch {
186
+ return undefined
187
+ }
188
+ }
189
+
190
+ /**
191
+ * The windows and the menu of the desktop app, through the shell. Every call is audited with the window it was about; a call with no shell attached answers one line that says what to run;
192
+ * closing the last window is refused unless the call says `force` (it ends the desktop app, and the session an agent works in with it).
193
+ */
194
+ private async desktop(verb: ShellOp, input: Record<string, unknown>, signal: AbortSignal): Promise<VerbAnswer> {
195
+ const shell = this.deps.shell
196
+ let request: ShellRequest
197
+ try {
198
+ request = parseShellRequest(verb, input)
199
+ } catch (cause) {
200
+ throw new DoorError('invalid', cause instanceof Error ? cause.message : 'invalid request')
201
+ }
202
+ const windowId = 'id' in request ? request.id : undefined
203
+ const record = (outcome: AuditEntry['outcome'], detail?: string, windowOf?: number): void => {
204
+ const id = windowOf ?? windowId
205
+ this.deps.audit.record({
206
+ at: new Date().toISOString(), tool: verb,
207
+ ...(request.op === 'menu-click' ? { target: { role: 'menuitem', name: request.path } } : {}),
208
+ ...(id === undefined ? {} : { window: id }),
209
+ outcome, ...(detail === undefined ? {} : { detail }), approval: 'token',
210
+ })
211
+ }
212
+ try {
213
+ if (shell === undefined || !shell.connected) throw new ShellError('no-desktop', NO_DESKTOP)
214
+ if (request.op === 'window-close' && request.force !== true) {
215
+ const { windows } = await shell.call({ op: 'window-list' }, { signal }) as Extract<ShellResult, { windows: unknown }>
216
+ if (windows.length === 1 && windows[0]?.id === request.id) {
217
+ throw new ShellError('last-window', `window ${String(request.id)} is the app's last window: closing it ends the desktop app. Open another window first, or pass force to end it on purpose`)
218
+ }
219
+ }
220
+ const result = await shell.call(request, { signal })
221
+ const openedId = 'id' in result ? result.id : undefined
222
+ record('ok', undefined, openedId)
223
+ return { value: result, text: renderShell(result) }
224
+ } catch (cause) {
225
+ if (!(cause instanceof ShellError)) throw cause
226
+ record(['invalid', 'no-such-window', 'last-window', 'no-such-menu-item', 'disabled', 'quits-app', 'no-desktop'].includes(cause.code) ? 'refused' : 'failed', cause.code)
227
+ throw new DoorError(cause.code, cause.message)
228
+ }
229
+ }
230
+
231
+ /** `window` on look and act: the desktop window (the number `window list` prints) whose page the call is for; without it, the page used last. */
232
+ private windowOf(input: Record<string, unknown>): { window: number | undefined; rest: Record<string, unknown> } {
233
+ const { window, ...rest } = input
234
+ if (window === undefined) return { window: undefined, rest }
235
+ if (typeof window !== 'number' || !Number.isSafeInteger(window) || window < 1) throw new DoorError('invalid', 'window must be the number `window list` prints for the window')
236
+ return { window, rest }
152
237
  }
153
238
 
154
239
  private parse(input: Record<string, unknown>): UiRequest {
@@ -160,17 +245,17 @@ export class Door {
160
245
  }
161
246
 
162
247
  /** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
163
- private async callPage(verb: string, request: UiRequest, signal: AbortSignal): Promise<UiResult> {
248
+ private async callPage(verb: string, request: UiRequest, signal: AbortSignal, window?: number): Promise<UiResult> {
164
249
  const approval: AuditEntry['approval'] = 'token'
165
250
  try {
166
- const value = await this.deps.channel.call(request, { signal, ...(request.op === 'wait' ? { timeoutMs: (request.timeoutMs ?? 3000) + 5000 } : {}) })
251
+ const value = await this.deps.channel.call(request, { signal, ...(window === undefined ? {} : { window }), ...(request.op === 'wait' ? { timeoutMs: (request.timeoutMs ?? 3000) + 5000 } : {}) })
167
252
  const target = 'target' in value ? value.target : undefined
168
- this.deps.audit.record({ at: new Date().toISOString(), tool: verb, ...(target === undefined ? {} : { target }), outcome: 'ok', approval })
253
+ this.deps.audit.record({ at: new Date().toISOString(), tool: verb, ...(target === undefined ? {} : { target }), ...(window === undefined ? {} : { window }), outcome: 'ok', approval })
169
254
  return value
170
255
  } catch (cause) {
171
256
  const code = cause instanceof UiControlError ? cause.code : 'failed'
172
257
  const refused = cause instanceof UiControlError && ['protected', 'sensitive', 'killed', 'invalid', 'stale-ref', 'unknown-ref', 'not-actionable'].includes(cause.code)
173
- this.deps.audit.record({ at: new Date().toISOString(), tool: verb, outcome: refused ? 'refused' : 'failed', detail: code, approval })
258
+ this.deps.audit.record({ at: new Date().toISOString(), tool: verb, ...(window === undefined ? {} : { window }), outcome: refused ? 'refused' : 'failed', detail: code, approval })
174
259
  throw cause
175
260
  }
176
261
  }
@@ -32,6 +32,8 @@ export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions
32
32
  const windowId = c.req.query('window')
33
33
  if (windowId === undefined || !WINDOW_ID.test(windowId)) return c.json(error('a window id is required'), 400)
34
34
  const focused = c.req.query('focused') === '1'
35
+ const shellQuery = Number(c.req.query('shell'))
36
+ const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : undefined
35
37
  const signal = c.req.raw.signal
36
38
  return streamSSE(c, async (stream) => {
37
39
  let end = (): void => {}
@@ -41,7 +43,7 @@ export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions
41
43
  const write = (event: string, data: string): void => {
42
44
  stream.writeSSE({ event, data }).catch(end)
43
45
  }
44
- const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, close: end })
46
+ const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, close: end }, shellWindow)
45
47
  const beat = setInterval(() => { write('ping', '') }, heartbeatMs)
46
48
  try {
47
49
  write('ready', '')
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The desktop shell's channel: the Electron main process of `npm run desktop`, which reaches the door as a page does (an event stream down, a POST up) and answers calls about
3
+ * the app's windows and menu. One shell at a time: a new one replaces the old (the dev server restarted and the shell came back). It needs this run's secret, so no page can open it.
4
+ */
5
+
6
+ import { Acryl } from 'acryl-router'
7
+ import { streamSSE } from 'acryl-router/helpers'
8
+ import { parseShellAnswer, ShellError, shellErrorCode, type ShellCall, type ShellRequest, type ShellResult } from '../shell/protocol.ts'
9
+ import { error, INVALID_JSON, jsonBody, operatorOnly, readJson } from './http.ts'
10
+ import type { PageStream } from './channel.ts'
11
+
12
+ const CALL_TIMEOUT_MS = 10_000
13
+ /** What a verb says when no desktop shell is attached: one line a model can act on. */
14
+ export const NO_DESKTOP = 'no desktop window is attached; run npm run desktop'
15
+
16
+ interface Pending {
17
+ readonly resolve: (value: ShellResult) => void
18
+ readonly reject: (cause: ShellError) => void
19
+ readonly timer: NodeJS.Timeout
20
+ readonly cleanup: () => void
21
+ }
22
+
23
+ export class ShellChannel {
24
+ private current: { readonly stream: PageStream } | undefined
25
+ private readonly pending = new Map<number, Pending>()
26
+ private nextId = 1
27
+ private closed = false
28
+ private readonly attachWaiters = new Set<() => void>()
29
+
30
+ get connected(): boolean {
31
+ return this.current !== undefined
32
+ }
33
+
34
+ /** @returns the function to call when this stream ends. */
35
+ attach(stream: PageStream): () => void {
36
+ const previous = this.current
37
+ const connection = { stream }
38
+ this.current = connection
39
+ if (previous !== undefined) {
40
+ this.failAll(new ShellError('disconnected', 'the desktop shell reconnected before it answered'))
41
+ previous.stream.close()
42
+ }
43
+ for (const waiter of [...this.attachWaiters]) waiter()
44
+ return () => {
45
+ if (this.current !== connection) return
46
+ this.current = undefined
47
+ this.failAll(new ShellError('disconnected', 'the desktop shell went away before it answered'))
48
+ }
49
+ }
50
+
51
+ /** A promise for the shell connecting (it reconnects after the dev server restarted). */
52
+ waitForShell(timeoutMs: number): Promise<boolean> {
53
+ if (this.current !== undefined) return Promise.resolve(true)
54
+ return new Promise((resolve) => {
55
+ const done = (value: boolean): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); resolve(value) }
56
+ const waiter = (): void => { done(true) }
57
+ const timer = setTimeout(() => { done(false) }, timeoutMs)
58
+ this.attachWaiters.add(waiter)
59
+ })
60
+ }
61
+
62
+ receive(body: unknown): void {
63
+ const answer = parseShellAnswer(body)
64
+ if (answer === null) return
65
+ if (answer.t === 'result') this.settle(answer.id, answer.value)
66
+ else this.settle(answer.id, undefined, new ShellError(shellErrorCode(answer.code), answer.message))
67
+ }
68
+
69
+ /** @throws ShellError `no-desktop` when no shell is attached, else what the shell refused with. */
70
+ call(request: ShellRequest, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<ShellResult> {
71
+ if (this.closed) return Promise.reject(new ShellError('unloaded', 'the door was unloaded'))
72
+ const target = this.current
73
+ if (target === undefined) return Promise.reject(new ShellError('no-desktop', NO_DESKTOP))
74
+ const signal = options.signal
75
+ if (signal?.aborted === true) return Promise.reject(new ShellError('aborted', 'the call was cancelled'))
76
+ const id = this.nextId
77
+ this.nextId += 1
78
+ return new Promise<ShellResult>((resolve, reject) => {
79
+ const timer = setTimeout(() => { this.settle(id, undefined, new ShellError('timeout', 'the desktop shell did not answer in time')) }, options.timeoutMs ?? CALL_TIMEOUT_MS)
80
+ const onAbort = (): void => { this.settle(id, undefined, new ShellError('aborted', 'the call was cancelled')) }
81
+ signal?.addEventListener('abort', onAbort, { once: true })
82
+ this.pending.set(id, { resolve, reject, timer, cleanup: () => { signal?.removeEventListener('abort', onAbort) } })
83
+ try {
84
+ target.stream.send(JSON.stringify({ t: 'call', id, request } satisfies ShellCall))
85
+ } catch {
86
+ this.settle(id, undefined, new ShellError('disconnected', 'the desktop shell could not be reached'))
87
+ }
88
+ })
89
+ }
90
+
91
+ close(): void {
92
+ this.closed = true
93
+ this.failAll(new ShellError('unloaded', 'the door was unloaded'))
94
+ this.current?.stream.close()
95
+ this.current = undefined
96
+ }
97
+
98
+ private failAll(failure: ShellError): void {
99
+ for (const id of [...this.pending.keys()]) this.settle(id, undefined, failure)
100
+ }
101
+
102
+ private settle(id: number, value: ShellResult | undefined, failure?: ShellError): void {
103
+ const entry = this.pending.get(id)
104
+ if (entry === undefined) return
105
+ this.pending.delete(id)
106
+ clearTimeout(entry.timer)
107
+ entry.cleanup()
108
+ if (failure !== undefined) entry.reject(failure)
109
+ else entry.resolve(value as ShellResult)
110
+ }
111
+ }
112
+
113
+ /** How often a `ping` is written, so the shell knows a silent stream died and reconnects. */
114
+ export const SHELL_HEARTBEAT_MS = 15_000
115
+
116
+ /** `GET <prefix>/shell/events` is the shell's stream and `POST <prefix>/shell/answer` carries its answers: both need this run's secret. */
117
+ export function shellRoutes(prefix: string, secret: string, channel: ShellChannel, heartbeatMs = SHELL_HEARTBEAT_MS): Acryl {
118
+ return new Acryl()
119
+ .get(`${prefix}/shell/events`, operatorOnly(secret), (c) => {
120
+ const signal = c.req.raw.signal
121
+ return streamSSE(c, async (stream) => {
122
+ let end = (): void => {}
123
+ const ended = new Promise<void>((resolve) => { end = resolve })
124
+ stream.onAbort(end)
125
+ signal.addEventListener('abort', end, { once: true })
126
+ const write = (event: string, data: string): void => {
127
+ stream.writeSSE({ event, data }).catch(end)
128
+ }
129
+ const detach = channel.attach({ send: (data) => { write('call', data) }, close: end })
130
+ const beat = setInterval(() => { write('ping', '') }, heartbeatMs)
131
+ try {
132
+ write('ready', '')
133
+ await ended
134
+ } finally {
135
+ clearInterval(beat)
136
+ signal.removeEventListener('abort', end)
137
+ detach()
138
+ }
139
+ })
140
+ })
141
+ .post(`${prefix}/shell/answer`, operatorOnly(secret), jsonBody, async (c) => {
142
+ const body = await readJson(c)
143
+ if (body === INVALID_JSON || typeof body !== 'object' || body === null) return c.json(error('invalid request body'), 400)
144
+ channel.receive(body)
145
+ return c.json({ ok: true })
146
+ })
147
+ }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { spawnSync } from 'node:child_process'
11
- import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
11
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
13
  import { ensureStateDir } from './door-file.ts'
14
14
 
@@ -70,11 +70,47 @@ export function windowsShim(root: string, target: ShimTarget): string {
70
70
  return `@echo off\r\nsetlocal\r\nrem This app's door (acryl-door): reaches only the app running from this folder. Rewritten each time the app starts.\r\n${env}${words} %*\r\n`
71
71
  }
72
72
 
73
- function writeAtomically(file: string, content: string, mode: number): void {
73
+ /** The error codes with which Windows refuses to replace a file that a running process (a `cmd.exe` executing the batch file) has open. */
74
+ const LOCKED = new Set(['EPERM', 'EBUSY', 'EACCES'])
75
+
76
+ export interface ShimFs {
77
+ readonly rename: (from: string, to: string) => void
78
+ readonly pause: (ms: number) => void
79
+ }
80
+
81
+ const realFs: ShimFs = {
82
+ rename: renameSync,
83
+ // A synchronous sleep: the door writes the shims while it starts, and the wait is a few milliseconds at most.
84
+ pause: (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) },
85
+ }
86
+
87
+ /**
88
+ * Write `content` to `file` so a reader never sees half of it. A shim that already holds this content is left alone: a restart of the app (the dev server restarts on a change)
89
+ * rewrites the same text, and on Windows replacing a batch file that an agent's `acryl` call is running fails with EPERM, which stopped the app from starting. A file that
90
+ * is held while its content differs is retried for about a second, and then reported: the old shim stays (it still works), and the app starts.
91
+ */
92
+ export function writeAtomically(file: string, content: string, mode: number, fs: ShimFs = realFs): void {
93
+ try {
94
+ if (readFileSync(file, 'utf8') === content) return
95
+ } catch {
96
+ // Not there yet, or unreadable: write it.
97
+ }
74
98
  const temporary = `${file}.${String(process.pid)}.tmp`
75
99
  writeFileSync(temporary, content, { mode })
76
100
  if (process.platform !== 'win32') chmodSync(temporary, mode)
77
- renameSync(temporary, file)
101
+ for (let attempt = 0; ; attempt += 1) {
102
+ try {
103
+ fs.rename(temporary, file)
104
+ return
105
+ } catch (cause) {
106
+ const code = (cause as NodeJS.ErrnoException).code
107
+ if (code === undefined || !LOCKED.has(code) || attempt >= 10) {
108
+ rmSync(temporary, { force: true })
109
+ throw cause
110
+ }
111
+ fs.pause(25 * (attempt + 1))
112
+ }
113
+ }
78
114
  }
79
115
 
80
116
  /** Write `.acryl/bin/acryl` and `.acryl/bin/acryl.cmd` for the app at `root`. @returns the folder they are in. */