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.
- package/docs/framework/agents/index.md +7 -5
- package/lib/accept-CNNHu4Cf.js +193 -0
- package/lib/{accepts-XBuZPVRI.js → accepts-Btq83xPd.js} +1 -1
- package/lib/accepts.js +1 -1
- package/lib/acryl-base.js +1 -331
- package/lib/adapter-CaZ6FZUU.js +37 -0
- package/lib/adapter.js +1 -36
- package/lib/{app-BM0rTCp7.js → app-CQcGWjrk.js} +4 -4
- package/lib/app-base-CpRMZL4v.js +332 -0
- package/lib/{audit-D5z9uwOP.d.ts → audit-GcLtvNLV.d.ts} +4 -2
- package/lib/{base-NSN_SNv-.js → base-D_6kfOr-.js} +1 -1
- package/lib/basic-auth-C0HTRpXh.js +20 -0
- package/lib/basic-auth-DFLflXNe.js +95 -0
- package/lib/basic-auth.js +1 -94
- package/lib/bearer-auth-Cwxt-vgS.js +94 -0
- package/lib/bearer-auth.js +1 -93
- package/lib/body-D7f4e7w_.js +94 -0
- package/lib/body-limit-WmIjWRLt.js +66 -0
- package/lib/body-limit.js +1 -65
- package/lib/buffer-CkEOJCcY.js +51 -0
- package/lib/bun.js +3 -3
- package/lib/cache-B0bHH7s5.js +169 -0
- package/lib/cache.js +1 -168
- package/lib/{client-DDLXwhBo.js → client-BrMFnMEd.js} +1 -1
- package/lib/{client-modules-CLvWOjbd.js → client-modules-drj4mjhj.js} +4 -4
- package/lib/client.js +1 -1
- package/lib/color-COa3DplO.js +37 -0
- package/lib/combine-TWuGdcbC.js +137 -0
- package/lib/combine.js +1 -136
- package/lib/{components-cEW3ovK0.js → components-B2ka9Ax1.js} +4 -4
- package/lib/{components-JXVm45kK.js → components-PVXwaOaC.js} +2 -2
- package/lib/compress-BzWqsHF1.js +79 -0
- package/lib/compress-C-9vK4B2.d.ts +11 -0
- package/lib/compress-DCyrrQ5D.js +11 -0
- package/lib/compress.d.ts +1 -1
- package/lib/compress.js +2 -78
- package/lib/concurrent-DrF3d2c-.js +30 -0
- package/lib/conninfo-BCROmdnf.d.ts +11 -0
- package/lib/conninfo-CWJwieh4.js +19 -0
- package/lib/constants-CBSEudrb.js +7 -0
- package/lib/{context-DDO0cfuP.js → context-CkOh7Ygv.js} +2 -2
- package/lib/context-storage-iuPoI_CR.js +52 -0
- package/lib/context-storage.js +1 -51
- package/lib/cookie-B1LlkzOe.js +125 -0
- package/lib/cookie-BHIduy2T.js +82 -0
- package/lib/cookie.js +1 -81
- package/lib/cordis/db-sqlite.d.ts +1 -1
- package/lib/cordis/door.d.ts +99 -5
- package/lib/cordis/door.js +562 -20
- package/lib/cordis/inspect.js +4 -4
- package/lib/cordis/kv-db.d.ts +1 -1
- package/lib/cordis/kv-db.js +1 -1
- package/lib/cordis/kv-json.d.ts +1 -1
- package/lib/cordis/kv-json.js +1 -1
- package/lib/cordis/node-server.d.ts +1 -1
- package/lib/cordis/node-server.js +3 -116
- package/lib/cordis/router-http.js +2 -2
- package/lib/cordis/router.js +4 -4
- package/lib/cordis/state.js +7 -7
- package/lib/{core-CaY8XDZ6.js → core-BOu1jYua.js} +1 -1
- package/lib/cors-CbAQ-8qU.js +106 -0
- package/lib/cors.js +1 -105
- package/lib/crypto-iVP-N-tS.js +34 -0
- package/lib/csrf-DqqOZtgy.js +101 -0
- package/lib/csrf.js +1 -100
- package/lib/css-C5CPVMvM.js +134 -0
- package/lib/css.js +2 -133
- package/lib/db/sqlite.d.ts +1 -25
- package/lib/dev-CLgu-JRe.js +50 -0
- package/lib/dev.js +1 -49
- package/lib/dom-YjND9Jt3.js +74 -0
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +3 -1
- package/lib/door/page.js +2 -2
- package/lib/door/protocol.d.ts +1 -1
- package/lib/door/protocol.js +1 -212
- package/lib/encode-BhbNLrjp.js +33 -0
- package/lib/factory-CLdSf-xS.js +30 -0
- package/lib/factory.js +1 -29
- package/lib/handler-C0wZ0KwO.js +12 -0
- package/lib/helpers-BEjcdbgw.js +5 -0
- package/lib/{hooks-w72iLeFR.js → hooks-CfC07ri0.js} +1 -1
- package/lib/html-CgNa89xw.js +31 -0
- package/lib/html-DGExBFNg.js +103 -0
- package/lib/html.d.ts +2 -5
- package/lib/html.js +2 -30
- package/lib/http-exception-MjWkT89V.js +54 -0
- package/lib/http-exception.js +1 -53
- package/lib/index-dAzQsJG9.d.ts +6 -0
- package/lib/index.js +3 -3
- package/lib/ip-restriction-jKtKCRM_.js +166 -0
- package/lib/ip-restriction.js +1 -165
- package/lib/ipaddr-DCoIo528.js +238 -0
- package/lib/jsx/dom/client.js +1 -1
- package/lib/jsx/dom/jsx-dev-runtime.js +1 -1
- package/lib/jsx/dom/jsx-runtime.js +2 -1
- package/lib/jsx/dom/server.js +3 -3
- package/lib/jsx/dom.js +5 -73
- package/lib/jsx/jsx-dev-runtime.d.ts +2 -5
- package/lib/jsx/jsx-dev-runtime.js +2 -13
- package/lib/jsx/jsx-runtime.d.ts +2 -2
- package/lib/jsx/jsx-runtime.js +4 -4
- package/lib/jsx/streaming.js +1 -1
- package/lib/jsx-CUQgttxU.js +47 -0
- package/lib/jsx-dev-runtime-B4WTyq8r.js +14 -0
- package/lib/{jsx-dev-runtime-CWLRA2F3.js → jsx-dev-runtime-BLfA-EhI.js} +2 -2
- package/lib/jsx-dev-runtime-CGt4kNYr.d.ts +6 -0
- package/lib/jsx-renderer-CfifM4hT.js +99 -0
- package/lib/jsx-renderer.js +1 -98
- package/lib/jsx-runtime-COCRWBJ9.js +2 -0
- package/lib/jsx.js +5 -46
- package/lib/{jwk-LkzeoaU8.js → jwk-CDPpYQpD.js} +3 -3
- package/lib/jwk.js +1 -1
- package/lib/{jwt-BGJBw5AF.js → jwt-BXoIA2fQ.js} +2 -2
- package/lib/{jwt-CskdhEW9.js → jwt-lyS4Q3mT.js} +3 -3
- package/lib/jwt.js +2 -2
- package/lib/kv/db.d.ts +1 -10
- package/lib/kv/db.js +1 -56
- package/lib/kv/json.d.ts +1 -9
- package/lib/kv/json.js +1 -223
- package/lib/{language-D9a4zqvn.js → language-DeGRtLJt.js} +2 -2
- package/lib/language.js +1 -1
- package/lib/{linear-router-C3FPib6h.js → linear-router-CmZn_JLV.js} +2 -2
- package/lib/logger-Ckxu8diz.js +54 -0
- package/lib/logger.js +1 -53
- package/lib/loopback-only-BbasoF7R.js +90 -0
- package/lib/loopback-only.js +1 -89
- package/lib/method-not-allowed-TO2_hnfl.js +82 -0
- package/lib/method-not-allowed.js +1 -81
- package/lib/method-override-DnZKfJbV.js +103 -0
- package/lib/method-override.js +1 -102
- package/lib/middleware-BfG_fp8P.js +12 -0
- package/lib/mime-BnScXkQn.js +77 -0
- package/lib/mount-QkK_3NtJ.js +67 -0
- package/lib/mount.js +1 -66
- package/lib/node-server/conninfo.d.ts +1 -10
- package/lib/node-server/conninfo.js +1 -18
- package/lib/node-server/register.js +25 -2
- package/lib/node-server/serve-static.d.ts +1 -17
- package/lib/node-server/serve-static.js +1 -1
- package/lib/node-server.d.ts +2 -2
- package/lib/node-server.js +4 -4
- package/lib/open-BqK4D2Wn.d.ts +11 -0
- package/lib/open-But9E2rL.js +57 -0
- package/lib/open-CULVJgOc.d.ts +12 -0
- package/lib/open-Dp4sxspc.js +224 -0
- package/lib/open-T7kgbO2O.d.ts +27 -0
- package/lib/{pattern-router-HZxjDGEQ.js → pattern-router-CJBecJBd.js} +1 -1
- package/lib/powered-by-CXQNjNIX.js +24 -0
- package/lib/powered-by.js +1 -23
- package/lib/pretty-json-CeC1yvYt.js +39 -0
- package/lib/pretty-json.js +1 -38
- package/lib/{protocol-C37gPePD.d.ts → protocol-CR31M7K_.d.ts} +2 -0
- package/lib/protocol-Dt0AZBOz.js +213 -0
- package/lib/proxy-C2w_mH7k.js +121 -0
- package/lib/proxy.js +1 -120
- package/lib/quick.js +4 -4
- package/lib/{reg-exp-router-DC1R3qoY.js → reg-exp-router-C_WV84eV.js} +2 -2
- package/lib/request-DvMgGtlW.js +323 -0
- package/lib/request.js +1 -322
- package/lib/route-D8m-a2I1.js +126 -0
- package/lib/route.js +1 -125
- package/lib/router/linear-router.js +1 -1
- package/lib/router/pattern-router.js +1 -1
- package/lib/router/reg-exp-router.js +1 -1
- package/lib/router/smart-router.js +1 -1
- package/lib/router/trie-router.js +1 -1
- package/lib/router-Be_o6nFu.js +35 -0
- package/lib/router.js +1 -34
- package/lib/schema-validator-EK3cUUCt.js +22 -0
- package/lib/schema-validator.js +1 -21
- package/lib/{secure-headers-mP3j6Hm6.js → secure-headers-C5ZOcXqk.js} +1 -1
- package/lib/secure-headers.js +1 -1
- package/lib/serve-static-B7sqnftW.d.ts +18 -0
- package/lib/{serve-static-3uo_9rjF.js → serve-static-C1Nkj_xB.js} +2 -2
- package/lib/{serve-static-Cp5adzGo.js → serve-static-EiJf1zkO.js} +4 -4
- package/lib/serve-static.js +1 -1
- package/lib/{smart-router-C5CZ5XSM.js → smart-router-BjxkDQCE.js} +1 -1
- package/lib/{snapshot-text-BLz8nlSc.js → snapshot-text-CtQm3n8n.js} +23 -14
- package/lib/{src-dy3Fk9tA.js → src-BTBm4OPD.js} +6 -6
- package/lib/src-LX0E4ec4.js +117 -0
- package/lib/{ssg-BwjcA-Ge.js → ssg-BEl7MHqg.js} +5 -5
- package/lib/ssg.js +1 -1
- package/lib/stream-CzM_SHkq.js +88 -0
- package/lib/{streaming-DHST3Scg.js → streaming-BlDaph8U.js} +3 -3
- package/lib/streaming.js +1 -1
- package/lib/{test-websocket-D8WWqE5G.js → test-websocket-BLeEvybj.js} +2 -2
- package/lib/testing-BQx5T_UX.js +17 -0
- package/lib/testing.js +1 -16
- package/lib/timeout-FeqZ9A-0.js +42 -0
- package/lib/timeout.js +1 -41
- package/lib/tiny.js +2 -2
- package/lib/trailing-slash-DwTx-1sk.js +85 -0
- package/lib/trailing-slash.js +1 -84
- package/lib/{trie-router-CfFbapzL.js → trie-router-JydLKUa2.js} +2 -2
- package/lib/url-BPh4d6j-.js +197 -0
- package/lib/utils/accept.js +1 -192
- package/lib/utils/basic-auth.js +1 -19
- package/lib/utils/body.js +1 -93
- package/lib/utils/buffer.js +1 -50
- package/lib/utils/color.js +1 -36
- package/lib/utils/compress.d.ts +1 -10
- package/lib/utils/compress.js +1 -10
- package/lib/utils/concurrent.js +1 -29
- package/lib/utils/constants.js +1 -6
- package/lib/utils/cookie.js +1 -124
- package/lib/utils/crypto.js +1 -33
- package/lib/utils/encode.js +1 -32
- package/lib/utils/handler.js +1 -11
- package/lib/utils/html.js +1 -102
- package/lib/utils/ipaddr.js +1 -237
- package/lib/utils/jwt.js +1 -1
- package/lib/utils/mime.js +1 -76
- package/lib/utils/stream.js +1 -87
- package/lib/utils/url.js +1 -196
- package/lib/{validator-D84mq-dO.js → validator-Btiyt5VJ.js} +3 -3
- package/lib/validator.js +1 -1
- package/lib/websocket-DsyBIKVA.js +56 -0
- package/lib/ws.js +1 -55
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +36 -5
- package/plugins/acryl-door/src/host/channel.ts +14 -6
- package/plugins/acryl-door/src/host/chat-tools.ts +2 -0
- package/plugins/acryl-door/src/host/door.ts +97 -12
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/shell.ts +147 -0
- package/plugins/acryl-door/src/host/shim.ts +39 -3
- package/plugins/acryl-door/src/host/verbs.ts +39 -2
- package/plugins/acryl-door/src/index.ts +22 -3
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/index.ts +2 -1
- package/plugins/acryl-door/src/page/protocol.ts +2 -0
- package/plugins/acryl-door/src/shell/protocol.ts +130 -0
- package/plugins/acryl-node-server/src/register.ts +29 -2
- package/plugins/agent/model.ts +6 -1
- package/plugins/agent/tools.ts +12 -2
- package/plugins/agent-panel/client/describe.ts +44 -0
- package/plugins/agent-panel/client/main.ts +4 -1
- package/plugins/agent-panel/headless.ts +29 -8
- package/plugins/agent-panel/index.ts +22 -10
- package/plugins/agent-panel/launch.ts +67 -0
- package/plugins/desktop/attach.ts +72 -0
- package/plugins/desktop/door-shell.ts +379 -0
- package/plugins/desktop/main.ts +82 -15
- package/plugins/extensions/index.ts +3 -4
- package/plugins/extensions/stage.ts +33 -5
- package/plugins/openai/index.ts +1 -1
- package/templates/agent/AGENTS.md +4 -0
- package/templates/agent/blend.yaml +4 -1
- package/templates/agent/src/app.ts +2 -0
- package/templates/agent/src/routes.ts +1 -1
- package/templates/agent/template.json +1 -0
- package/templates/agent/test/builder.test.ts +26 -21
- package/lib/helpers-CvTauQT5.js +0 -5
- package/lib/middleware-CPQ8himN.js +0 -12
- package/plugins/acryl-router/src/connection.d.ts +0 -25
- package/plugins/acryl-router/src/core/app-base.d.ts +0 -215
- package/plugins/acryl-router/src/core/app.d.ts +0 -19
- package/plugins/acryl-router/src/core/client/client.d.ts +0 -4
- package/plugins/acryl-router/src/core/client/fetch-result-please.d.ts +0 -35
- package/plugins/acryl-router/src/core/client/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/client/types.d.ts +0 -229
- package/plugins/acryl-router/src/core/client/utils.d.ts +0 -18
- package/plugins/acryl-router/src/core/compose.d.ts +0 -14
- package/plugins/acryl-router/src/core/context.d.ts +0 -443
- package/plugins/acryl-router/src/core/helper/accepts/accepts.d.ts +0 -30
- package/plugins/acryl-router/src/core/helper/accepts/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/helper/conninfo/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/helper/conninfo/types.d.ts +0 -36
- package/plugins/acryl-router/src/core/helper/cookie/index.d.ts +0 -25
- package/plugins/acryl-router/src/core/helper/css/common.d.ts +0 -59
- package/plugins/acryl-router/src/core/helper/css/index.d.ts +0 -80
- package/plugins/acryl-router/src/core/helper/dev/index.d.ts +0 -20
- package/plugins/acryl-router/src/core/helper/factory/index.d.ts +0 -82
- package/plugins/acryl-router/src/core/helper/html/index.d.ts +0 -8
- package/plugins/acryl-router/src/core/helper/proxy/index.d.ts +0 -73
- package/plugins/acryl-router/src/core/helper/route/index.d.ts +0 -75
- package/plugins/acryl-router/src/core/helper/ssg/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/helper/ssg/middleware.d.ts +0 -44
- package/plugins/acryl-router/src/core/helper/ssg/plugins.d.ts +0 -27
- package/plugins/acryl-router/src/core/helper/ssg/ssg.d.ts +0 -91
- package/plugins/acryl-router/src/core/helper/ssg/utils.d.ts +0 -16
- package/plugins/acryl-router/src/core/helper/streaming/index.d.ts +0 -8
- package/plugins/acryl-router/src/core/helper/streaming/sse.d.ts +0 -13
- package/plugins/acryl-router/src/core/helper/streaming/stream.d.ts +0 -3
- package/plugins/acryl-router/src/core/helper/streaming/text.d.ts +0 -3
- package/plugins/acryl-router/src/core/helper/streaming/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/helper/testing/index.d.ts +0 -13
- package/plugins/acryl-router/src/core/helper/websocket/index.d.ts +0 -70
- package/plugins/acryl-router/src/core/http-exception.d.ts +0 -58
- package/plugins/acryl-router/src/core/index.d.ts +0 -37
- package/plugins/acryl-router/src/core/jsx/base.d.ts +0 -61
- package/plugins/acryl-router/src/core/jsx/children.d.ts +0 -9
- package/plugins/acryl-router/src/core/jsx/components.d.ts +0 -15
- package/plugins/acryl-router/src/core/jsx/constants.d.ts +0 -6
- package/plugins/acryl-router/src/core/jsx/context.d.ts +0 -51
- package/plugins/acryl-router/src/core/jsx/dom/components.d.ts +0 -10
- package/plugins/acryl-router/src/core/jsx/dom/context.d.ts +0 -3
- package/plugins/acryl-router/src/core/jsx/dom/css.d.ts +0 -83
- package/plugins/acryl-router/src/core/jsx/dom/hooks/index.d.ts +0 -39
- package/plugins/acryl-router/src/core/jsx/dom/intrinsic-element/components.d.ts +0 -16
- package/plugins/acryl-router/src/core/jsx/dom/jsx-dev-runtime.d.ts +0 -8
- package/plugins/acryl-router/src/core/jsx/dom/jsx-runtime.d.ts +0 -7
- package/plugins/acryl-router/src/core/jsx/dom/render.d.ts +0 -73
- package/plugins/acryl-router/src/core/jsx/dom/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/jsx/hooks/index.d.ts +0 -47
- package/plugins/acryl-router/src/core/jsx/index.d.ts +0 -76
- package/plugins/acryl-router/src/core/jsx/intrinsic-element/common.d.ts +0 -6
- package/plugins/acryl-router/src/core/jsx/intrinsic-element/components.d.ts +0 -13
- package/plugins/acryl-router/src/core/jsx/intrinsic-elements.d.ts +0 -735
- package/plugins/acryl-router/src/core/jsx/streaming.d.ts +0 -36
- package/plugins/acryl-router/src/core/jsx/types.d.ts +0 -36
- package/plugins/acryl-router/src/core/jsx/utils.d.ts +0 -4
- package/plugins/acryl-router/src/core/request/constants.d.ts +0 -1
- package/plugins/acryl-router/src/core/request.d.ts +0 -306
- package/plugins/acryl-router/src/core/router/reg-exp-router/index.d.ts +0 -6
- package/plugins/acryl-router/src/core/router/reg-exp-router/matcher.d.ts +0 -7
- package/plugins/acryl-router/src/core/router/reg-exp-router/node.d.ts +0 -13
- package/plugins/acryl-router/src/core/router/reg-exp-router/prepared-router.d.ts +0 -17
- package/plugins/acryl-router/src/core/router/reg-exp-router/router.d.ts +0 -11
- package/plugins/acryl-router/src/core/router/reg-exp-router/trie.d.ts +0 -8
- package/plugins/acryl-router/src/core/router/smart-router/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/router/smart-router/router.d.ts +0 -11
- package/plugins/acryl-router/src/core/router/trie-router/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/router/trie-router/node.d.ts +0 -6
- package/plugins/acryl-router/src/core/router/trie-router/router.d.ts +0 -7
- package/plugins/acryl-router/src/core/router/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/router.d.ts +0 -97
- package/plugins/acryl-router/src/core/types.d.ts +0 -573
- package/plugins/acryl-router/src/core/utils/accept.d.ts +0 -6
- package/plugins/acryl-router/src/core/utils/body.d.ts +0 -79
- package/plugins/acryl-router/src/core/utils/buffer.d.ts +0 -17
- package/plugins/acryl-router/src/core/utils/color.d.ts +0 -21
- package/plugins/acryl-router/src/core/utils/concurrent.d.ts +0 -11
- package/plugins/acryl-router/src/core/utils/constants.d.ts +0 -4
- package/plugins/acryl-router/src/core/utils/cookie.d.ts +0 -41
- package/plugins/acryl-router/src/core/utils/crypto.d.ts +0 -15
- package/plugins/acryl-router/src/core/utils/handler.d.ts +0 -6
- package/plugins/acryl-router/src/core/utils/headers.d.ts +0 -8
- package/plugins/acryl-router/src/core/utils/html.d.ts +0 -44
- package/plugins/acryl-router/src/core/utils/http-status.d.ts +0 -32
- package/plugins/acryl-router/src/core/utils/mime.d.ts +0 -70
- package/plugins/acryl-router/src/core/utils/stream.d.ts +0 -31
- package/plugins/acryl-router/src/core/utils/types.d.ts +0 -128
- package/plugins/acryl-router/src/core/utils/url.d.ts +0 -44
- package/plugins/acryl-router/src/core/validator/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/validator/utils.d.ts +0 -29
- package/plugins/acryl-router/src/core/validator/validator.d.ts +0 -16
- package/plugins/acryl-router/src/helpers/index.d.ts +0 -17
- package/plugins/acryl-router/src/helpers/schema-validator.d.ts +0 -54
package/lib/ws.js
CHANGED
|
@@ -1,56 +1,2 @@
|
|
|
1
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
134
|
-
|
|
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
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|