acrylblends 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/index.d.ts +47 -2
- package/dist/index.js +2017 -32
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +5 -3
- package/docs/framework/workshop/README.md +139 -153
- package/docs/framework/workshop/facilitator.md +69 -101
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +87 -9
- package/lib/cordis/door.js +324 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -1
- package/lib/door/page.js +46 -7
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +63 -6
- package/plugins/acryl-door/src/host/channel.ts +86 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +128 -24
- package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +14 -1
- package/plugins/acryl-door/src/index.ts +168 -30
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +41 -2
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +238 -26
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +146 -57
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/builder/index.ts +13 -35
- package/plugins/builder/package.ts +186 -0
- package/plugins/builder/plugin.json +3 -1
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +47 -18
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +6 -0
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/plugins/plugin-share/README.md +51 -0
- package/plugins/plugin-share/index.ts +467 -0
- package/plugins/plugin-share/plugin.json +15 -0
- package/plugins/plugin-share/publish.ts +65 -0
- package/plugins/plugin-share/pull-check.ts +129 -0
- package/plugins/plugin-share/pulled-records.ts +70 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/.gitattributes +2 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +6 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +154 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +38 -19
- package/templates/agent/template.json +3 -0
- package/templates/agent/test/app.test.ts +45 -3
- package/templates/blank/.gitattributes +2 -0
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/.gitattributes +2 -0
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/.gitattributes +2 -0
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/.gitattributes +2 -0
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
|
@@ -18,8 +18,9 @@ import { clientModules } from 'acryl-node-server/client-modules'
|
|
|
18
18
|
import { auditPath, AuditLog } from './host/audit.ts'
|
|
19
19
|
import { UiChannel } from './host/channel.ts'
|
|
20
20
|
import { registerChatTools } from './host/chat-tools.ts'
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
21
|
+
import { PAGE_VERBS, VERBS } from './host/verbs.ts'
|
|
22
|
+
import { Door, type ExtensionsPort, type PluginSharePort, type ToolsPort } from './host/door.ts'
|
|
23
|
+
import { assertDoorIsFree, newSecret, removeDoorFile, stateDir, writeDoorFile } from './host/door-file.ts'
|
|
23
24
|
import { DOOR_PREFIX, privateApi } from './host/http.ts'
|
|
24
25
|
import { mcpRoutes } from './host/mcp-routes.ts'
|
|
25
26
|
import { operatorRoutes } from './host/operator-routes.ts'
|
|
@@ -37,9 +38,10 @@ interface AppFolder {
|
|
|
37
38
|
readonly name: string
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
/** The
|
|
41
|
+
/** The address the carrier listens on, as the `acrylWeb` service gives it (host and port; DSH's webServer serves it today). */
|
|
41
42
|
interface Listening {
|
|
42
|
-
readonly
|
|
43
|
+
readonly host: string
|
|
44
|
+
readonly port: number
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -48,7 +50,17 @@ declare module '@deepseek-ai/cordis' {
|
|
|
48
50
|
}
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
// Two hosts serve the same door. On DSH (slice 1 of the IDE port) the carrier's address comes from `acrylWeb`, upgrades from `routerHttp`, and the page script
|
|
54
|
+
// goes into DSH's own index through the web server's index-inject hook (the framework's router.wrap never sees that page). On the plain node-server host the
|
|
55
|
+
// address and upgrades come from `nodeServer`, and `router.wrap` adds the script. Only `router` and `appInstance` are required; a host without the optional
|
|
56
|
+
// services simply takes the other path, so the door never stays PENDING beside a server that does not provide them.
|
|
57
|
+
export const inject = ['router', 'appInstance']
|
|
58
|
+
|
|
59
|
+
/** The services a host may provide in place of `acrylWeb`/`routerHttp` (the plain node-server host). */
|
|
60
|
+
interface NodeServerLike {
|
|
61
|
+
readonly url?: string
|
|
62
|
+
upgrades?: () => UpgradeWebSocket
|
|
63
|
+
}
|
|
52
64
|
|
|
53
65
|
export interface Config {
|
|
54
66
|
tools?: { expose?: string[] }
|
|
@@ -57,13 +69,34 @@ export interface Config {
|
|
|
57
69
|
* run it in production (a packaged desktop app whose user drives it with an agent) says so here; `ACRYL_DOOR=off` still wins.
|
|
58
70
|
*/
|
|
59
71
|
allowInProduction?: boolean
|
|
72
|
+
/**
|
|
73
|
+
* Page only, for a production assistant: the page channel and two chat tools, `door_look` and `door_act`, and nothing else - no operator routes, no MCP, no desktop
|
|
74
|
+
* shell channel, no `.acryl/` file, no plugin, tool or window verbs. The app's agent can then use the page for a visitor and cannot change the app. Runs in production
|
|
75
|
+
* without `allowInProduction`; `ACRYL_DOOR=off` still wins.
|
|
76
|
+
*/
|
|
77
|
+
pageOnly?: boolean
|
|
60
78
|
/** Reload the open pages after a live plugin change, so an agent that built a feature sees it on the page it has open. Default: true. */
|
|
61
79
|
reloadOnChange?: boolean
|
|
80
|
+
/**
|
|
81
|
+
* Put the page script in the host's own index page (DSH's web UI) too, so an agent can drive that UI. Default: true. A host whose index is a frame
|
|
82
|
+
* around the app (the ACRYL Blends frame, whose centre shows the app's page) turns it off: the door then drives the app's page in the canvas, and an
|
|
83
|
+
* agent's `act navigate` or a reload after a plugin change never takes the person's tab out of the frame.
|
|
84
|
+
*/
|
|
85
|
+
hostIndex?: boolean
|
|
86
|
+
/**
|
|
87
|
+
* The host prefix the app's pages are served under (the frame's canvas, `/app`); absent on the app's own server, where they are at the root. The
|
|
88
|
+
* door puts it on every page as `<meta name="acryl-app-base">`, and `act navigate` with an app path goes under it: a page the app serves at
|
|
89
|
+
* `/board` is reached at `/app/board` in the frame and at `/board` in production, and the same verb works in both.
|
|
90
|
+
*/
|
|
91
|
+
appBase?: string
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
export const Config: z<Config> = z.object({
|
|
65
95
|
allowInProduction: z.boolean().default(false).description('Run in production too (NODE_ENV=production); ACRYL_DOOR=off still turns it off.'),
|
|
96
|
+
pageOnly: z.boolean().default(false).description('Only the page and the look and act chat tools (a production assistant); runs in production.'),
|
|
66
97
|
reloadOnChange: z.boolean().default(true).description('Reload the open pages after a plugin was added, removed or reloaded while the app runs.'),
|
|
98
|
+
hostIndex: z.boolean().default(true).description("Put the page script in the host's own index page too; off when that index is a frame around the app."),
|
|
99
|
+
appBase: z.string().default('').description('The prefix the app pages are served under (the frame canvas), without a trailing slash; empty at the root.'),
|
|
67
100
|
tools: z.object({
|
|
68
101
|
// Not a `door_` name: those are the door's own verbs in the registry, never offered back as app tools.
|
|
69
102
|
expose: z.array(z.string().pattern(/^(?!door_)[A-Za-z][A-Za-z0-9_-]{0,63}$/u)).description('App tools (from the tools registry) the door offers; none by default.'),
|
|
@@ -107,24 +140,49 @@ export function notFoundPage(path: string, scriptTag: string): string {
|
|
|
107
140
|
export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
108
141
|
const config = Config(rawConfig)
|
|
109
142
|
// Development only: nothing is started, mounted or written in production, or when switched off.
|
|
110
|
-
|
|
143
|
+
const pageOnly = config.pageOnly === true
|
|
144
|
+
if (process.env.ACRYL_DOOR === 'off' || (process.env.NODE_ENV === 'production' && config.allowInProduction !== true && !pageOnly)) return
|
|
111
145
|
const app = ctx.get('appInstance') as unknown as AppFolder
|
|
112
|
-
|
|
146
|
+
// The carrier, as the host provides it: DSH's `acrylWeb`, or the plain node server's own url. Read when used, never captured at apply time.
|
|
147
|
+
const carrier = (): Listening | undefined => {
|
|
148
|
+
const web = ctx.get('acrylWeb') as unknown as Listening | undefined
|
|
149
|
+
if (web !== undefined) return web
|
|
150
|
+
const node = ctx.get('nodeServer') as unknown as NodeServerLike | undefined
|
|
151
|
+
if (typeof node?.url !== 'string') return undefined
|
|
152
|
+
try {
|
|
153
|
+
const parsed = new URL(node.url)
|
|
154
|
+
return { host: parsed.hostname, port: Number(parsed.port) }
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
}
|
|
113
159
|
if (typeof app?.root !== 'string') throw new Error('acryl-door: the appInstance service has no app folder (root)')
|
|
114
160
|
const reportError = (operation: string, cause: unknown): void => {
|
|
115
161
|
ctx.logger?.error?.(`acryl-door: failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
116
162
|
}
|
|
163
|
+
const address: { url: string } = { url: '' }
|
|
164
|
+
// The rival check runs at activation, not in the deferred write: a folder another live process serves fails the row at once (the write waits for a bound port).
|
|
165
|
+
// A page-only door writes no file, so it has no rival to check.
|
|
166
|
+
if (!pageOnly) assertDoorIsFree(app.root)
|
|
117
167
|
const secret = newSecret()
|
|
118
168
|
// A look right after a navigate or reload waits up to two seconds for the page that is moving to register again, and a read-only call that no page acknowledged gives a page that just registered one more try.
|
|
119
|
-
|
|
169
|
+
// A page-only door serves every visitor of the app on one channel: each call goes to the page of the visitor whose run made it, never to another visitor's page.
|
|
170
|
+
const channel = new UiChannel({ attachWaitMs: 2000, requirePage: pageOnly })
|
|
120
171
|
const shell = new ShellChannel()
|
|
121
172
|
const audit = new AuditLog(auditPath(stateDir(app.root)))
|
|
173
|
+
// Where the app's pages answer under this host (`/app` in the frame): `act navigate` sends an app path there, and a page may read it from the
|
|
174
|
+
// head. The app's own server has no prefix and no meta.
|
|
175
|
+
const rawBase = typeof config.appBase === 'string' ? config.appBase.trim() : ''
|
|
176
|
+
const appBase = rawBase.startsWith('/') && rawBase !== '/' ? rawBase.replace(/\/+$/u, '') : ''
|
|
122
177
|
const door = new Door({
|
|
123
178
|
channel,
|
|
124
179
|
shell,
|
|
125
180
|
audit,
|
|
126
|
-
|
|
181
|
+
// The address is read when it is used, not when the plugin applies: the carrier binds after the fiber starts (DSH's webServer reports its listening port only then).
|
|
182
|
+
app: { name: app.name, root: app.root, get url() { return address.url } },
|
|
183
|
+
...(appBase === '' ? {} : { appBase }),
|
|
127
184
|
extensions: () => ctx.get('extensions') as unknown as ExtensionsPort | undefined,
|
|
185
|
+
pluginShare: () => ctx.get('pluginShare') as unknown as PluginSharePort | undefined,
|
|
128
186
|
tools: () => ctx.get('tools') as unknown as ToolsPort | undefined,
|
|
129
187
|
expose: config.tools?.expose ?? [],
|
|
130
188
|
reloadOnChange: config.reloadOnChange !== false,
|
|
@@ -134,10 +192,11 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
134
192
|
// One script tag for the page (`page.js`): it imports the module tree served below it.
|
|
135
193
|
const pageModule = new Acryl()
|
|
136
194
|
.get(`${DOOR_PREFIX}/page.js`, c => c.body(`import '${DOOR_PREFIX}/page/main.ts'\n`, 200, { 'content-type': 'text/javascript; charset=utf-8' }))
|
|
137
|
-
.route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR }))
|
|
195
|
+
.route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR, mount: `${DOOR_PREFIX}/page` }))
|
|
138
196
|
// The page script on every HTML page the app answers, whenever its plugin was mounted (the pages of a plugin that loaded at boot, before this one, included): a page an agent writes
|
|
139
|
-
// can be looked at and driven without anyone adding the tag. A page that already carries it
|
|
197
|
+
// can be looked at and driven without anyone adding the tag. A page that already carries it keeps one copy. Development only, like the rest of the door.
|
|
140
198
|
const tag = `<script type="module" src="${DOOR_PREFIX}/page.js"></script>`
|
|
199
|
+
const meta = appBase === '' ? '' : `<meta name="acryl-app-base" content="${appBase}">`
|
|
141
200
|
const addScript = async (c: { res: Response; req: { path: string; header(name: string): string | undefined } }, next: () => Promise<void>): Promise<void> => {
|
|
142
201
|
await next()
|
|
143
202
|
if (!(c.res.headers.get('content-type') ?? '').includes('text/html')) {
|
|
@@ -147,48 +206,127 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
147
206
|
c.res = new Response(notFoundPage(c.req.path, tag), { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
148
207
|
return
|
|
149
208
|
}
|
|
150
|
-
|
|
209
|
+
let html = await c.res.text()
|
|
151
210
|
const headers = new Headers(c.res.headers)
|
|
152
211
|
headers.delete('content-length')
|
|
153
|
-
|
|
154
|
-
|
|
212
|
+
if (meta !== '' && !html.includes('name="acryl-app-base"'))
|
|
213
|
+
html = html.includes('</head>') ? html.replace('</head>', `${meta}</head>`) : meta + html
|
|
214
|
+
if (!html.includes(`${DOOR_PREFIX}/page.js`))
|
|
215
|
+
html = html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag
|
|
216
|
+
c.res = new Response(html, { status: c.res.status, headers })
|
|
155
217
|
}
|
|
156
218
|
const router = ctx.router as unknown as { wrap?: (middleware: typeof addScript) => unknown }
|
|
157
219
|
// An app whose router copy is older than this plugin has no `wrap`: the middleware then covers the routes mounted after the door, as the agent panel's does.
|
|
158
220
|
if (typeof router.wrap === 'function') router.wrap(addScript)
|
|
159
221
|
else ctx.router.route('/', new Acryl().use('*', addScript as never))
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
222
|
+
// DSH's own index.html is not a framework route, so `router.wrap` never sees it: the tag goes in through the web server's index-inject hook instead. The
|
|
223
|
+
// tap is owned by this plugin's fiber, so unloading the door takes the tag with it, and the new activation's page routes answer under the new secret.
|
|
224
|
+
if (config.hostIndex !== false) ctx.inject(['webServer'], inner => {
|
|
225
|
+
inner.effect(() => {
|
|
226
|
+
const taps = inner.get('webServer') as unknown as { tapIndex?: (transform: (html: string) => string) => () => void } | undefined
|
|
227
|
+
if (typeof taps?.tapIndex !== 'function') return () => {}
|
|
228
|
+
return taps.tapIndex(html => {
|
|
229
|
+
if (html.includes(`${DOOR_PREFIX}/page.js`)) return html
|
|
230
|
+
return html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag
|
|
231
|
+
})
|
|
232
|
+
}, 'acryl-door: the page script in the host index')
|
|
233
|
+
})
|
|
234
|
+
// The page channel also runs over a WebSocket, which is not one of the browser's six connections per host. Without `ws` or an adapter that
|
|
235
|
+
// upgrades, the pages use the stream. The socket route is mounted ONCE here, ahead of the private routes, so it keeps its precedence when the host
|
|
236
|
+
// restarts; what moves is the upgrade helper it reads per request. While routerHttp exists its inject scope owns the prefix claim and sets the
|
|
237
|
+
// helper (Cordis disposes the claim exactly when the service goes away); the node server's hub is the helper only while routerHttp is absent.
|
|
238
|
+
// An upgrade while no helper is ready is refused 503 by the route, never answered twice.
|
|
239
|
+
let pageUpgrade: UpgradeWebSocket | undefined
|
|
240
|
+
let nodeUpgrade: UpgradeWebSocket | undefined
|
|
241
|
+
const endNodeOwner = (): void => {
|
|
242
|
+
if (pageUpgrade === nodeUpgrade) pageUpgrade = undefined
|
|
243
|
+
nodeUpgrade = undefined
|
|
244
|
+
}
|
|
245
|
+
const nodeFallback = (): void => {
|
|
246
|
+
if (ctx.get('routerHttp') !== undefined || nodeUpgrade !== undefined) return
|
|
247
|
+
try {
|
|
248
|
+
const allow = (ctx.get('nodeServer') as unknown as NodeServerLike | undefined)?.upgrades?.()
|
|
249
|
+
if (allow !== undefined) {
|
|
250
|
+
nodeUpgrade = allow
|
|
251
|
+
pageUpgrade = allow
|
|
252
|
+
}
|
|
253
|
+
} catch (cause) {
|
|
254
|
+
reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
|
|
255
|
+
}
|
|
166
256
|
}
|
|
257
|
+
ctx.inject(['routerHttp'], (inner) => {
|
|
258
|
+
// The adapter is here: the node helper gives way, and this scope owns the prefix claim and the helper until Cordis disposes it with the service.
|
|
259
|
+
endNodeOwner()
|
|
260
|
+
const http = inner.get('routerHttp') as unknown as
|
|
261
|
+
| { upgrades?: () => UpgradeWebSocket; mount?: (prefix: string, options: { upgrade: string[] }) => () => void }
|
|
262
|
+
| undefined
|
|
263
|
+
try {
|
|
264
|
+
const allow = http?.upgrades?.()
|
|
265
|
+
if (allow === undefined || http === undefined || typeof http.mount !== 'function') return
|
|
266
|
+
pageUpgrade = allow
|
|
267
|
+
const remove = http.mount(DOOR_PREFIX, { upgrade: [`${DOOR_PREFIX}/channel/ws`] })
|
|
268
|
+
inner.effect(() => () => {
|
|
269
|
+
remove()
|
|
270
|
+
if (pageUpgrade === allow) pageUpgrade = undefined
|
|
271
|
+
nodeFallback()
|
|
272
|
+
}, 'acryl-door: the page socket via routerHttp')
|
|
273
|
+
} catch (cause) {
|
|
274
|
+
reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
ctx.inject(['nodeServer'], (inner) => {
|
|
278
|
+
inner.effect(() => {
|
|
279
|
+
nodeFallback()
|
|
280
|
+
const onService = inner.on as unknown as (name: string, listener: () => void) => () => void
|
|
281
|
+
const off = onService('internal/service', () => {
|
|
282
|
+
if (ctx.get('routerHttp') !== undefined) endNodeOwner()
|
|
283
|
+
else nodeFallback()
|
|
284
|
+
})
|
|
285
|
+
return () => {
|
|
286
|
+
off()
|
|
287
|
+
endNodeOwner()
|
|
288
|
+
}
|
|
289
|
+
}, 'acryl-door: the page socket on the node server')
|
|
290
|
+
})
|
|
291
|
+
ctx.router.route('/', pageSocketRoutes(channel, { prefix: DOOR_PREFIX, upgrade: () => pageUpgrade }))
|
|
167
292
|
ctx.router.route('/', privateApi(
|
|
168
293
|
DOOR_PREFIX,
|
|
169
294
|
pageModule,
|
|
170
295
|
pageChannelRoutes(channel, { prefix: DOOR_PREFIX }),
|
|
171
|
-
...(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
296
|
+
...(pageOnly ? [] : [
|
|
297
|
+
shellRoutes(DOOR_PREFIX, secret, shell),
|
|
298
|
+
operatorRoutes(DOOR_PREFIX, secret, door, reportError),
|
|
299
|
+
mcpRoutes(DOOR_PREFIX, secret, door, reportError),
|
|
300
|
+
]),
|
|
175
301
|
))
|
|
176
302
|
// The built-in chat's agent reads the `tools` registry: the verbs are in it while both are up.
|
|
177
303
|
ctx.inject(['tools'], (inner) => {
|
|
178
|
-
inner.effect(() => registerChatTools(inner.get('tools') as unknown as ToolsPort, door), 'acryl-door: the verbs in the tools registry')
|
|
304
|
+
inner.effect(() => registerChatTools(inner.get('tools') as unknown as ToolsPort, door, pageOnly ? PAGE_VERBS : VERBS), 'acryl-door: the verbs in the tools registry')
|
|
179
305
|
})
|
|
180
306
|
ctx.effect(function* () {
|
|
181
307
|
yield ctx.reflect.provide('door', door)
|
|
182
|
-
|
|
308
|
+
}, 'acryl-door: the door service')
|
|
309
|
+
// A page-only door leaves no file and no command for an operator.
|
|
310
|
+
if (pageOnly) return
|
|
311
|
+
// The carrier binds asynchronously (DSH's webServer, the node server before it): the address and the file are written once it reports a real port.
|
|
312
|
+
ctx.effect(async () => {
|
|
313
|
+
let bound = carrier()
|
|
314
|
+
for (let at = 0; at < 500 && (bound === undefined || !(bound.port > 0)); at += 1) {
|
|
315
|
+
await new Promise((done) => setTimeout(done, 10))
|
|
316
|
+
bound = carrier()
|
|
317
|
+
}
|
|
318
|
+
if (bound === undefined) throw new Error('acryl-door: no carrier bound the app (neither acrylWeb nor nodeServer answered)')
|
|
319
|
+
address.url = loopbackUrl(`http://${bound.host}:${String(bound.port)}`)
|
|
320
|
+
writeDoorFile(app.root, address.url, secret)
|
|
183
321
|
// A shim that cannot be replaced (Windows, held open by a running `acryl`) leaves the old one in place; it must not stop the app from starting.
|
|
184
322
|
try { writeShims(app.root, shimTarget()) } catch (cause) { reportError('write .acryl/bin', cause) }
|
|
185
|
-
|
|
186
|
-
}, 'acryl-door:
|
|
323
|
+
return () => { removeDoorFile(app.root, secret) }
|
|
324
|
+
}, 'acryl-door: .acryl/door.json and .acryl/bin/acryl')
|
|
187
325
|
}
|
|
188
326
|
|
|
189
327
|
export { Door, DoorError, type VerbAnswer } from './host/door.ts'
|
|
190
|
-
export { chatToolName, VERB_SPECS, VERBS, type Verb, type VerbSpec } from './host/verbs.ts'
|
|
328
|
+
export { chatToolName, PAGE_VERBS, VERB_SPECS, VERBS, type Verb, type VerbSpec } from './host/verbs.ts'
|
|
191
329
|
export { mcpTools } from './host/mcp-routes.ts'
|
|
192
330
|
export { DOOR_PREFIX } from './host/http.ts'
|
|
193
|
-
export { doorFilePath, readDoorFile, STATE_DIR, type DoorFile } from './host/door-file.ts'
|
|
331
|
+
export { assertDoorIsFree, doorFilePath, readDoorFile, STATE_DIR, type DoorFile } from './host/door-file.ts'
|
|
194
332
|
export type { CallResponse } from './host/operator-routes.ts'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a host serves the app's pages under a prefix (the ACRYL frame's canvas, `/app`): the meta the door puts on each page, and where an app path
|
|
3
|
+
* lands under that prefix. Empty at the root, which is the app's own server.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one thing this file needs from a page's document. Spelled without DOM types so the Host imports `underAppBase` from here too, instead of keeping
|
|
8
|
+
* a second copy of the join; a real Document satisfies it.
|
|
9
|
+
*/
|
|
10
|
+
interface MetaHost {
|
|
11
|
+
querySelector(selectors: string): { getAttribute(name: string): string | null } | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The app base a page carries (`<meta name="acryl-app-base">`, the host's prefix for the app); '' at the root. */
|
|
15
|
+
export function appBaseOf(document: MetaHost): string {
|
|
16
|
+
const content = document.querySelector('meta[name="acryl-app-base"]')?.getAttribute('content')?.trim() ?? ''
|
|
17
|
+
return content.startsWith('/') && content !== '/' ? content.replace(/\/+$/u, '') : ''
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `path` (an app path, optionally with a query) under `base`: '/board' under '/app' is '/app/board'; the app's root is the base itself, so '/' is
|
|
22
|
+
* '/app' (its mount) and not '/app/'; without a base, the path as written.
|
|
23
|
+
*/
|
|
24
|
+
export function underAppBase(base: string, path: string): string {
|
|
25
|
+
if (base === '') return path
|
|
26
|
+
const at = path.search(/[?#]/u)
|
|
27
|
+
const route = at === -1 ? path : path.slice(0, at)
|
|
28
|
+
const rest = at === -1 ? '' : path.slice(at)
|
|
29
|
+
if (route === base || route.startsWith(`${base}/`)) return path
|
|
30
|
+
if (route === '/' || route === '') return `${base}${rest}`
|
|
31
|
+
return `${base}${route.startsWith('/') ? '' : '/'}${route}${rest}`
|
|
32
|
+
}
|
|
@@ -27,6 +27,8 @@ const BACKOFF_MAX_MS = 10_000
|
|
|
27
27
|
|
|
28
28
|
export interface PageChannelOptions {
|
|
29
29
|
readonly windowId: string
|
|
30
|
+
/** The browser tab the page is in (kept across the tab's reloads and moves), so the Host follows a run's page into the next document of its tab. */
|
|
31
|
+
readonly tab?: string
|
|
30
32
|
/** The desktop window this page is in (`AcrylWindow/<n>` in the user agent, set by the Electron shell), so the Host can drive it by the number `window list` prints. */
|
|
31
33
|
readonly shellWindow?: number
|
|
32
34
|
readonly focused: () => boolean
|
|
@@ -70,7 +72,7 @@ export class PageChannel {
|
|
|
70
72
|
|
|
71
73
|
connect(): void {
|
|
72
74
|
if (this.disposed || this.source !== undefined) return
|
|
73
|
-
const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}${this.options.shellWindow === undefined ? '' : `&shell=${String(this.options.shellWindow)}`}`
|
|
75
|
+
const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}${this.options.shellWindow === undefined ? '' : `&shell=${String(this.options.shellWindow)}`}${this.options.tab === undefined ? '' : `&tab=${encodeURIComponent(this.options.tab)}`}`
|
|
74
76
|
const viaSocket = this.socketAllowed
|
|
75
77
|
this.socketReady = false
|
|
76
78
|
const source: ChannelSource = viaSocket
|
|
@@ -147,6 +147,17 @@ export function valueOf(el: Element, role: string): string | undefined {
|
|
|
147
147
|
return value === undefined || value === '' ? undefined : clip(value, 100)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/** Choices of a select listed at most; a longer list ends with how many more there are. */
|
|
151
|
+
const MAX_OPTIONS = 20
|
|
152
|
+
|
|
153
|
+
/** The choices of a native select (what `act select` takes), or undefined when it is not one or is sensitive. */
|
|
154
|
+
export function optionsOf(el: Element): string[] | undefined {
|
|
155
|
+
if (!(el instanceof HTMLSelectElement) || isSensitive(el)) return undefined
|
|
156
|
+
const labels = [...el.options].filter(option => !option.hidden).map(option => clip((option.textContent ?? '').trim(), 60)).filter(label => label !== '')
|
|
157
|
+
if (labels.length <= MAX_OPTIONS) return labels
|
|
158
|
+
return [...labels.slice(0, MAX_OPTIONS), `(${String(labels.length - MAX_OPTIONS)} more)`]
|
|
159
|
+
}
|
|
160
|
+
|
|
150
161
|
/** Heading level, when the element is a heading. */
|
|
151
162
|
export function levelOf(el: Element): number | undefined {
|
|
152
163
|
const explicit = Number(el.getAttribute('aria-level'))
|
|
@@ -16,6 +16,8 @@ export interface ActionEnvironment {
|
|
|
16
16
|
readonly document: () => Document
|
|
17
17
|
/** Resolves after `ms`; rejects when `signal` aborts. Injectable so tests need no real time. */
|
|
18
18
|
readonly sleep: (ms: number, signal: AbortSignal) => Promise<void>
|
|
19
|
+
/** Called with the element an act is about to use, so the page can show the person what the agent is touching. */
|
|
20
|
+
readonly touched?: (element: Element) => void
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const target = (role: string, name: string): { role: string; name: string } => ({ role, name })
|
|
@@ -35,6 +37,7 @@ function prepare(env: ActionEnvironment, ref: string): { element: Element; role:
|
|
|
35
37
|
if ((element as HTMLButtonElement).disabled === true || element.getAttribute('aria-disabled') === 'true') {
|
|
36
38
|
throw new UiControlError('not-actionable', `"${resolved.name}" is disabled`)
|
|
37
39
|
}
|
|
40
|
+
env.touched?.(element)
|
|
38
41
|
return resolved
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -149,6 +152,7 @@ export function scroll(env: ActionEnvironment, request: Request<'scroll'>): UiAc
|
|
|
149
152
|
const dy = request.direction === 'up' ? -amount : request.direction === 'down' ? amount : 0
|
|
150
153
|
if (request.ref !== undefined) {
|
|
151
154
|
const { element, role, name } = env.refs.resolve(request.ref)
|
|
155
|
+
env.touched?.(element)
|
|
152
156
|
;(element as HTMLElement).scrollBy?.({ left: dx, top: dy })
|
|
153
157
|
if (typeof (element as HTMLElement).scrollBy !== 'function') { element.scrollTop += dy; element.scrollLeft += dx }
|
|
154
158
|
return { ok: true, target: target(role, name), detail: `scrolled ${request.direction}` }
|
|
@@ -27,11 +27,16 @@ export interface DriverActivity {
|
|
|
27
27
|
readonly killed: boolean
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** How long after an act a navigation it started is still noticed (a link's navigation starts a task or two after the click). */
|
|
31
|
+
const LEAVE_WATCH_MS = 50
|
|
32
|
+
|
|
30
33
|
export interface UiDriverOptions {
|
|
31
34
|
readonly document: () => Document
|
|
32
35
|
/** Milliseconds since the user last used the page (mouse, keyboard); Infinity when never. */
|
|
33
36
|
readonly msSinceUserInput?: () => number
|
|
34
37
|
readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>
|
|
38
|
+
/** Called with the element an act is about to use, for the page to show it (the door's outline on what the agent touches). */
|
|
39
|
+
readonly touched?: (element: Element) => void
|
|
35
40
|
/** Leave the page: reload it, or go to a path of the app. Default: the document's own `location`, a moment after the answer is sent. */
|
|
36
41
|
readonly leave?: (to: { readonly reload: true } | { readonly path: string }) => void
|
|
37
42
|
}
|
|
@@ -63,7 +68,12 @@ export class UiDriver {
|
|
|
63
68
|
constructor(options: UiDriverOptions) {
|
|
64
69
|
this.options = options
|
|
65
70
|
this.snapshots = new SnapshotBuilder(this.refs, () => options.document().body)
|
|
66
|
-
this.env = {
|
|
71
|
+
this.env = {
|
|
72
|
+
refs: this.refs,
|
|
73
|
+
document: options.document,
|
|
74
|
+
sleep: options.sleep ?? realSleep,
|
|
75
|
+
...(options.touched === undefined ? {} : { touched: options.touched }),
|
|
76
|
+
}
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
/** The current activity; the same object until something changes, as a store snapshot must be. */
|
|
@@ -142,10 +152,10 @@ export class UiDriver {
|
|
|
142
152
|
private async run(request: UiRequest, signal: AbortSignal): Promise<UiResult> {
|
|
143
153
|
switch (request.op) {
|
|
144
154
|
case 'snapshot': return this.snapshots.snapshot({ ...(request.cursor === undefined ? {} : { cursor: request.cursor }), ...(request.maxNodes === undefined ? {} : { maxNodes: request.maxNodes }) })
|
|
145
|
-
case 'click': return click(this.env, request)
|
|
146
|
-
case 'type': return typeText(this.env, request)
|
|
155
|
+
case 'click': return await this.watchLeaving(() => click(this.env, request), signal)
|
|
156
|
+
case 'type': return await this.watchLeaving(() => typeText(this.env, request), signal)
|
|
147
157
|
case 'select': return selectOption(this.env, request)
|
|
148
|
-
case 'press': return pressKey(this.env, request)
|
|
158
|
+
case 'press': return await this.watchLeaving(() => pressKey(this.env, request), signal)
|
|
149
159
|
case 'scroll': return scroll(this.env, request)
|
|
150
160
|
case 'wait': return wait(this.env, request, signal)
|
|
151
161
|
case 'reload': return this.leave({ reload: true }, 'reloading')
|
|
@@ -153,6 +163,24 @@ export class UiDriver {
|
|
|
153
163
|
}
|
|
154
164
|
}
|
|
155
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Run an act that may start a navigation (a click on a link, Enter in a form) and say so: the browser asks `beforeunload` when a navigation starts, during the act or a
|
|
168
|
+
* moment after it. The answer is sent before the page goes; the Host then waits for the next page instead of reading this one again.
|
|
169
|
+
*/
|
|
170
|
+
private async watchLeaving(act: () => UiActionResult, signal: AbortSignal): Promise<UiActionResult> {
|
|
171
|
+
const view = this.options.document().defaultView
|
|
172
|
+
let leaving = false
|
|
173
|
+
const onLeave = (): void => { leaving = true }
|
|
174
|
+
view?.addEventListener('beforeunload', onLeave)
|
|
175
|
+
try {
|
|
176
|
+
const result = act()
|
|
177
|
+
if (!leaving) await this.env.sleep(LEAVE_WATCH_MS, signal)
|
|
178
|
+
return leaving ? { ...result, leaving: true, detail: `${result.detail === undefined ? '' : `${result.detail}; `}the page is going to another address` } : result
|
|
179
|
+
} finally {
|
|
180
|
+
view?.removeEventListener('beforeunload', onLeave)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
156
184
|
/**
|
|
157
185
|
* The page is about to be replaced, so the answer is given first and the move happens a moment later (the answer is a POST that the page's own unload would cut off).
|
|
158
186
|
* The Host does not wait for this answer to be the last word: it waits for the page to come back.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Builds a bounded, redacted accessibility snapshot of the page, with refs. */
|
|
2
2
|
|
|
3
3
|
import { DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES, UiControlError, type UiNode, type UiSnapshot } from '../protocol.ts'
|
|
4
|
-
import { isHidden, levelOf, NAMED_BY_CONTENT, nameOf, roleOf, SHOWN_AS_VALUE, statesOf, TEXT_ROLE, textRun, valueOf } from './accessibility.ts'
|
|
4
|
+
import { isHidden, levelOf, NAMED_BY_CONTENT, nameOf, optionsOf, roleOf, SHOWN_AS_VALUE, statesOf, TEXT_ROLE, textRun, valueOf } from './accessibility.ts'
|
|
5
5
|
import type { RefTable } from './ref-table.ts'
|
|
6
6
|
import { isProtected, isSensitive } from './sensitivity.ts'
|
|
7
7
|
|
|
@@ -90,7 +90,8 @@ export class SnapshotBuilder {
|
|
|
90
90
|
const root = this.root()
|
|
91
91
|
const view = root.ownerDocument.defaultView
|
|
92
92
|
let offset = options.cursor ?? 0
|
|
93
|
-
|
|
93
|
+
// Cursor 0 is the first page, which is a new snapshot: a model that passes it on its first look gets one instead of a stale-ref.
|
|
94
|
+
if (offset === 0) {
|
|
94
95
|
this.refs.begin()
|
|
95
96
|
const all = collect(root)
|
|
96
97
|
// What the user can see comes first, so a long page is cut from the bottom, not the middle of the window.
|
|
@@ -105,6 +106,7 @@ export class SnapshotBuilder {
|
|
|
105
106
|
if (isProtected(candidate.element)) states.push('protected')
|
|
106
107
|
const value = valueOf(candidate.element, candidate.role)
|
|
107
108
|
const level = candidate.role === 'heading' ? levelOf(candidate.element) : undefined
|
|
109
|
+
const options = optionsOf(candidate.element)
|
|
108
110
|
return {
|
|
109
111
|
ref: this.refs.issue(candidate.element, candidate.role, candidate.name),
|
|
110
112
|
role: candidate.role,
|
|
@@ -113,6 +115,7 @@ export class SnapshotBuilder {
|
|
|
113
115
|
states,
|
|
114
116
|
...(value === undefined ? {} : { value }),
|
|
115
117
|
...(level === undefined ? {} : { level }),
|
|
118
|
+
...(options === undefined ? {} : { options }),
|
|
116
119
|
}
|
|
117
120
|
})
|
|
118
121
|
const end = offset + page.length
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* the kill switch. It touches only this document, never another window and never the OS.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { appBaseOf, underAppBase } from './base.ts'
|
|
7
8
|
import { PageChannel } from './channel.ts'
|
|
8
9
|
import { UiDriver } from './driver/driver.ts'
|
|
9
10
|
import { mountIndicator } from './indicator.ts'
|
|
11
|
+
import { mountOutlines } from './outline.ts'
|
|
10
12
|
import { UserInputClock } from './user-input.ts'
|
|
11
13
|
|
|
12
14
|
export const DOOR_BASE = '/api/acryl-door'
|
|
@@ -14,14 +16,50 @@ export const DOOR_BASE = '/api/acryl-door'
|
|
|
14
16
|
/** The key on the page's `window` under which the door publishes `{ windowId }`: the agent panel in the same page sends it with a question, and the door drives that page for the run. */
|
|
15
17
|
export const PAGE_KEY = Symbol.for('acryl.door.page')
|
|
16
18
|
|
|
19
|
+
/** Where the page keeps its tab's id: per tab, kept across reloads and moves within the app, never shared with another tab. */
|
|
20
|
+
const TAB_KEY = 'acryl-door-tab'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* This tab's id: the page that replaces this one in the same tab (a reload, a move to another path) says the same, so the Host keeps driving that tab for a run asked from it.
|
|
24
|
+
* `undefined` when the browser keeps no session storage.
|
|
25
|
+
*/
|
|
26
|
+
function tabOf(view: Window): string | undefined {
|
|
27
|
+
try {
|
|
28
|
+
const kept = view.sessionStorage.getItem(TAB_KEY)
|
|
29
|
+
if (kept !== null && /^[A-Za-z0-9-]{8,80}$/u.test(kept)) return kept
|
|
30
|
+
const made = typeof view.crypto?.randomUUID === 'function' ? view.crypto.randomUUID() : undefined
|
|
31
|
+
if (made === undefined) return undefined
|
|
32
|
+
view.sessionStorage.setItem(TAB_KEY, made)
|
|
33
|
+
return made
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
/** Start the door on `document`. @returns the disposer. */
|
|
18
40
|
export function startDoor(document: Document, base: string = DOOR_BASE): () => void {
|
|
19
41
|
const view = document.defaultView
|
|
20
42
|
if (view === null) throw new Error('acryl-door: the document has no window')
|
|
21
43
|
const clock = new UserInputClock()
|
|
22
|
-
const
|
|
44
|
+
const appBase = appBaseOf(document)
|
|
45
|
+
// The ring on whatever the agent touches, drawn in this page (the frame's canvas and production's assistant both get it).
|
|
46
|
+
const outlines = mountOutlines(document)
|
|
47
|
+
const driver = new UiDriver({
|
|
48
|
+
document: () => document,
|
|
49
|
+
msSinceUserInput: clock.msSince,
|
|
50
|
+
touched: (element) => { outlines.touched(element) },
|
|
51
|
+
// A host that serves the app under a prefix (the ACRYL frame's canvas) marks its pages: an app path navigates under that prefix, so the same
|
|
52
|
+
// `act navigate --path /board` reaches the app's /board in the frame and on the app's own server.
|
|
53
|
+
...(appBase === '' ? {} : {
|
|
54
|
+
leave: (to: { readonly reload: true } | { readonly path: string }): void => {
|
|
55
|
+
if ('reload' in to) view.location.reload()
|
|
56
|
+
else view.location.assign(underAppBase(appBase, to.path))
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
})
|
|
23
60
|
const windowId = typeof view.crypto?.randomUUID === 'function' ? view.crypto.randomUUID() : `w-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`
|
|
24
61
|
Object.defineProperty(view, PAGE_KEY, { value: Object.freeze({ windowId }), configurable: true })
|
|
62
|
+
const tab = tabOf(view)
|
|
25
63
|
const focused = (): boolean => document.visibilityState === 'visible' && document.hasFocus()
|
|
26
64
|
const detachInput = clock.attach(document)
|
|
27
65
|
const removeIndicator = mountIndicator(driver, document)
|
|
@@ -34,7 +72,7 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
34
72
|
return 'auto'
|
|
35
73
|
}
|
|
36
74
|
})()
|
|
37
|
-
const channel = new PageChannel(driver, { windowId, focused, base, transport, onRun: running => { driver.setRunning(running) }, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
|
|
75
|
+
const channel = new PageChannel(driver, { windowId, ...(tab === undefined ? {} : { tab }), focused, base, transport, onRun: running => { driver.setRunning(running) }, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
|
|
38
76
|
const reportFocus = (): void => { channel.reportFocus(focused()) }
|
|
39
77
|
view.addEventListener('focus', reportFocus)
|
|
40
78
|
view.addEventListener('blur', reportFocus)
|
|
@@ -54,6 +92,7 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
54
92
|
Reflect.deleteProperty(view, PAGE_KEY)
|
|
55
93
|
channel.dispose()
|
|
56
94
|
removeIndicator()
|
|
95
|
+
outlines.dispose()
|
|
57
96
|
detachInput()
|
|
58
97
|
// Pending calls settle as `unloaded` and every ref goes stale.
|
|
59
98
|
driver.dispose()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A green ring on the element the agent just used, for a moment (docs/design/blends-frame-mockup.html outlines the "My tasks" the assistant opened). It is
|
|
3
|
+
* drawn here, in the page itself, so the person sees what the agent touched in the frame's canvas and in production's assistant alike, and so it goes on its
|
|
4
|
+
* own: nothing outside the page has to draw it, position it, or take it away.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const STYLE_ID = 'acryl-door-outline-style'
|
|
8
|
+
/** The class the ring is drawn with: the page's element keeps its own styles, and an outline follows the element's shape. */
|
|
9
|
+
const CLASS = 'acrylDoorOutline'
|
|
10
|
+
/** How long the ring stays after the agent's last act; each act replaces it, so one run leaves one ring, a moment after its last step. */
|
|
11
|
+
export const OUTLINE_MS = 2000
|
|
12
|
+
|
|
13
|
+
const STYLES = `.${CLASS} { outline: 2px solid #3fb67b !important; outline-offset: 1px; }`
|
|
14
|
+
|
|
15
|
+
export interface Outlines {
|
|
16
|
+
/** Ring `element` as what the agent just used; the previous ring goes. */
|
|
17
|
+
touched(element: Element): void
|
|
18
|
+
/** Take the ring off and remove the style: nothing of this remains in the page. */
|
|
19
|
+
dispose(): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Draw the ring in `document`. @returns the control the driver calls, and the disposer that leaves the page as it was. */
|
|
23
|
+
export function mountOutlines(document: Document): Outlines {
|
|
24
|
+
if (document.getElementById(STYLE_ID) === null) {
|
|
25
|
+
const style = document.createElement('style')
|
|
26
|
+
style.id = STYLE_ID
|
|
27
|
+
style.textContent = STYLES
|
|
28
|
+
document.head.append(style)
|
|
29
|
+
}
|
|
30
|
+
let ringed: Element | undefined
|
|
31
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
32
|
+
const clear = (): void => {
|
|
33
|
+
if (timer !== undefined) {
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
timer = undefined
|
|
36
|
+
}
|
|
37
|
+
ringed?.classList.remove(CLASS)
|
|
38
|
+
ringed = undefined
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
touched(element: Element): void {
|
|
42
|
+
// The same control again in one run keeps its ring where it is; the person sees one ring, not a flicker.
|
|
43
|
+
if (element !== ringed) {
|
|
44
|
+
ringed?.classList.remove(CLASS)
|
|
45
|
+
ringed = element
|
|
46
|
+
element.classList.add(CLASS)
|
|
47
|
+
}
|
|
48
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
49
|
+
timer = setTimeout(clear, OUTLINE_MS)
|
|
50
|
+
},
|
|
51
|
+
dispose(): void {
|
|
52
|
+
clear()
|
|
53
|
+
document.getElementById(STYLE_ID)?.remove()
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|