acrylblends 1.0.10 → 1.2.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 +60 -2
- package/dist/index.js +1992 -33
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +1 -1
- package/docs/framework/guides/production.md +20 -0
- package/docs/framework/workshop/README.md +174 -285
- package/docs/framework/workshop/facilitator.md +81 -90
- 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-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +44 -7
- package/lib/cordis/door.js +214 -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.d.ts +3 -2
- package/lib/cordis/state.js +371 -7
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +20 -1
- package/lib/door/page.js +75 -14
- 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-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
- 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/src/host/channel.ts +124 -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 +58 -23
- 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 +3 -0
- package/plugins/acryl-door/src/index.ts +166 -29
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +11 -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 +49 -7
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +46 -2
- package/plugins/acryl-door/src/page/indicator.ts +14 -6
- 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-state/plugin.json +4 -3
- package/plugins/acryl-state/src/client-source.ts +244 -0
- package/plugins/acryl-state/src/service.ts +33 -6
- package/plugins/acryl-state/src/socket.ts +96 -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 +343 -81
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +187 -61
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- 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 +26 -1
- package/plugins/extensions/resolve.ts +13 -0
- 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 +12 -3
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -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/AGENTS.md +4 -2
- package/templates/agent/blend.yaml +4 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +150 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +73 -21
- package/templates/agent/template.json +2 -0
- package/templates/agent/test/app.test.ts +83 -5
- 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/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/main.ts +1 -6
- 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/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/main.ts +1 -6
- 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/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/main.ts +1 -6
- 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/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 { PAGE_VERBS, VERBS } from './host/verbs.ts'
|
|
21
22
|
import { Door, type ExtensionsPort, type ToolsPort } from './host/door.ts'
|
|
22
|
-
import { newSecret, removeDoorFile, stateDir, writeDoorFile } from './host/door-file.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,23 +140,47 @@ 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,
|
|
128
185
|
tools: () => ctx.get('tools') as unknown as ToolsPort | undefined,
|
|
129
186
|
expose: config.tools?.expose ?? [],
|
|
@@ -134,10 +191,11 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
134
191
|
// One script tag for the page (`page.js`): it imports the module tree served below it.
|
|
135
192
|
const pageModule = new Acryl()
|
|
136
193
|
.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 }))
|
|
194
|
+
.route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR, mount: `${DOOR_PREFIX}/page` }))
|
|
138
195
|
// 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
|
|
196
|
+
// 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
197
|
const tag = `<script type="module" src="${DOOR_PREFIX}/page.js"></script>`
|
|
198
|
+
const meta = appBase === '' ? '' : `<meta name="acryl-app-base" content="${appBase}">`
|
|
141
199
|
const addScript = async (c: { res: Response; req: { path: string; header(name: string): string | undefined } }, next: () => Promise<void>): Promise<void> => {
|
|
142
200
|
await next()
|
|
143
201
|
if (!(c.res.headers.get('content-type') ?? '').includes('text/html')) {
|
|
@@ -147,48 +205,127 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
147
205
|
c.res = new Response(notFoundPage(c.req.path, tag), { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
148
206
|
return
|
|
149
207
|
}
|
|
150
|
-
|
|
208
|
+
let html = await c.res.text()
|
|
151
209
|
const headers = new Headers(c.res.headers)
|
|
152
210
|
headers.delete('content-length')
|
|
153
|
-
|
|
154
|
-
|
|
211
|
+
if (meta !== '' && !html.includes('name="acryl-app-base"'))
|
|
212
|
+
html = html.includes('</head>') ? html.replace('</head>', `${meta}</head>`) : meta + html
|
|
213
|
+
if (!html.includes(`${DOOR_PREFIX}/page.js`))
|
|
214
|
+
html = html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag
|
|
215
|
+
c.res = new Response(html, { status: c.res.status, headers })
|
|
155
216
|
}
|
|
156
217
|
const router = ctx.router as unknown as { wrap?: (middleware: typeof addScript) => unknown }
|
|
157
218
|
// 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
219
|
if (typeof router.wrap === 'function') router.wrap(addScript)
|
|
159
220
|
else ctx.router.route('/', new Acryl().use('*', addScript as never))
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
221
|
+
// 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
|
|
222
|
+
// 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.
|
|
223
|
+
if (config.hostIndex !== false) ctx.inject(['webServer'], inner => {
|
|
224
|
+
inner.effect(() => {
|
|
225
|
+
const taps = inner.get('webServer') as unknown as { tapIndex?: (transform: (html: string) => string) => () => void } | undefined
|
|
226
|
+
if (typeof taps?.tapIndex !== 'function') return () => {}
|
|
227
|
+
return taps.tapIndex(html => {
|
|
228
|
+
if (html.includes(`${DOOR_PREFIX}/page.js`)) return html
|
|
229
|
+
return html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag
|
|
230
|
+
})
|
|
231
|
+
}, 'acryl-door: the page script in the host index')
|
|
232
|
+
})
|
|
233
|
+
// 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
|
|
234
|
+
// 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
|
|
235
|
+
// restarts; what moves is the upgrade helper it reads per request. While routerHttp exists its inject scope owns the prefix claim and sets the
|
|
236
|
+
// helper (Cordis disposes the claim exactly when the service goes away); the node server's hub is the helper only while routerHttp is absent.
|
|
237
|
+
// An upgrade while no helper is ready is refused 503 by the route, never answered twice.
|
|
238
|
+
let pageUpgrade: UpgradeWebSocket | undefined
|
|
239
|
+
let nodeUpgrade: UpgradeWebSocket | undefined
|
|
240
|
+
const endNodeOwner = (): void => {
|
|
241
|
+
if (pageUpgrade === nodeUpgrade) pageUpgrade = undefined
|
|
242
|
+
nodeUpgrade = undefined
|
|
243
|
+
}
|
|
244
|
+
const nodeFallback = (): void => {
|
|
245
|
+
if (ctx.get('routerHttp') !== undefined || nodeUpgrade !== undefined) return
|
|
246
|
+
try {
|
|
247
|
+
const allow = (ctx.get('nodeServer') as unknown as NodeServerLike | undefined)?.upgrades?.()
|
|
248
|
+
if (allow !== undefined) {
|
|
249
|
+
nodeUpgrade = allow
|
|
250
|
+
pageUpgrade = allow
|
|
251
|
+
}
|
|
252
|
+
} catch (cause) {
|
|
253
|
+
reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
|
|
254
|
+
}
|
|
166
255
|
}
|
|
256
|
+
ctx.inject(['routerHttp'], (inner) => {
|
|
257
|
+
// 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.
|
|
258
|
+
endNodeOwner()
|
|
259
|
+
const http = inner.get('routerHttp') as unknown as
|
|
260
|
+
| { upgrades?: () => UpgradeWebSocket; mount?: (prefix: string, options: { upgrade: string[] }) => () => void }
|
|
261
|
+
| undefined
|
|
262
|
+
try {
|
|
263
|
+
const allow = http?.upgrades?.()
|
|
264
|
+
if (allow === undefined || http === undefined || typeof http.mount !== 'function') return
|
|
265
|
+
pageUpgrade = allow
|
|
266
|
+
const remove = http.mount(DOOR_PREFIX, { upgrade: [`${DOOR_PREFIX}/channel/ws`] })
|
|
267
|
+
inner.effect(() => () => {
|
|
268
|
+
remove()
|
|
269
|
+
if (pageUpgrade === allow) pageUpgrade = undefined
|
|
270
|
+
nodeFallback()
|
|
271
|
+
}, 'acryl-door: the page socket via routerHttp')
|
|
272
|
+
} catch (cause) {
|
|
273
|
+
reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
ctx.inject(['nodeServer'], (inner) => {
|
|
277
|
+
inner.effect(() => {
|
|
278
|
+
nodeFallback()
|
|
279
|
+
const onService = inner.on as unknown as (name: string, listener: () => void) => () => void
|
|
280
|
+
const off = onService('internal/service', () => {
|
|
281
|
+
if (ctx.get('routerHttp') !== undefined) endNodeOwner()
|
|
282
|
+
else nodeFallback()
|
|
283
|
+
})
|
|
284
|
+
return () => {
|
|
285
|
+
off()
|
|
286
|
+
endNodeOwner()
|
|
287
|
+
}
|
|
288
|
+
}, 'acryl-door: the page socket on the node server')
|
|
289
|
+
})
|
|
290
|
+
ctx.router.route('/', pageSocketRoutes(channel, { prefix: DOOR_PREFIX, upgrade: () => pageUpgrade }))
|
|
167
291
|
ctx.router.route('/', privateApi(
|
|
168
292
|
DOOR_PREFIX,
|
|
169
293
|
pageModule,
|
|
170
294
|
pageChannelRoutes(channel, { prefix: DOOR_PREFIX }),
|
|
171
|
-
...(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
295
|
+
...(pageOnly ? [] : [
|
|
296
|
+
shellRoutes(DOOR_PREFIX, secret, shell),
|
|
297
|
+
operatorRoutes(DOOR_PREFIX, secret, door, reportError),
|
|
298
|
+
mcpRoutes(DOOR_PREFIX, secret, door, reportError),
|
|
299
|
+
]),
|
|
175
300
|
))
|
|
176
301
|
// The built-in chat's agent reads the `tools` registry: the verbs are in it while both are up.
|
|
177
302
|
ctx.inject(['tools'], (inner) => {
|
|
178
|
-
inner.effect(() => registerChatTools(inner.get('tools') as unknown as ToolsPort, door), 'acryl-door: the verbs in the tools registry')
|
|
303
|
+
inner.effect(() => registerChatTools(inner.get('tools') as unknown as ToolsPort, door, pageOnly ? PAGE_VERBS : VERBS), 'acryl-door: the verbs in the tools registry')
|
|
179
304
|
})
|
|
180
305
|
ctx.effect(function* () {
|
|
181
306
|
yield ctx.reflect.provide('door', door)
|
|
182
|
-
|
|
307
|
+
}, 'acryl-door: the door service')
|
|
308
|
+
// A page-only door leaves no file and no command for an operator.
|
|
309
|
+
if (pageOnly) return
|
|
310
|
+
// 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.
|
|
311
|
+
ctx.effect(async () => {
|
|
312
|
+
let bound = carrier()
|
|
313
|
+
for (let at = 0; at < 500 && (bound === undefined || !(bound.port > 0)); at += 1) {
|
|
314
|
+
await new Promise((done) => setTimeout(done, 10))
|
|
315
|
+
bound = carrier()
|
|
316
|
+
}
|
|
317
|
+
if (bound === undefined) throw new Error('acryl-door: no carrier bound the app (neither acrylWeb nor nodeServer answered)')
|
|
318
|
+
address.url = loopbackUrl(`http://${bound.host}:${String(bound.port)}`)
|
|
319
|
+
writeDoorFile(app.root, address.url, secret)
|
|
183
320
|
// 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
321
|
try { writeShims(app.root, shimTarget()) } catch (cause) { reportError('write .acryl/bin', cause) }
|
|
185
|
-
|
|
186
|
-
}, 'acryl-door:
|
|
322
|
+
return () => { removeDoorFile(app.root, secret) }
|
|
323
|
+
}, 'acryl-door: .acryl/door.json and .acryl/bin/acryl')
|
|
187
324
|
}
|
|
188
325
|
|
|
189
326
|
export { Door, DoorError, type VerbAnswer } from './host/door.ts'
|
|
190
|
-
export { chatToolName, VERB_SPECS, VERBS, type Verb, type VerbSpec } from './host/verbs.ts'
|
|
327
|
+
export { chatToolName, PAGE_VERBS, VERB_SPECS, VERBS, type Verb, type VerbSpec } from './host/verbs.ts'
|
|
191
328
|
export { mcpTools } from './host/mcp-routes.ts'
|
|
192
329
|
export { DOOR_PREFIX } from './host/http.ts'
|
|
193
|
-
export { doorFilePath, readDoorFile, STATE_DIR, type DoorFile } from './host/door-file.ts'
|
|
330
|
+
export { assertDoorIsFree, doorFilePath, readDoorFile, STATE_DIR, type DoorFile } from './host/door-file.ts'
|
|
194
331
|
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
|
|
@@ -41,6 +43,8 @@ export interface PageChannelOptions {
|
|
|
41
43
|
readonly post?: (url: string, body: string) => Promise<unknown>
|
|
42
44
|
readonly silenceMs?: number
|
|
43
45
|
readonly now?: () => number
|
|
46
|
+
/** The Host says a run asked for from this page's panel started (`true`) or ended (`false`); false too when the stream is lost, until the Host says again. */
|
|
47
|
+
readonly onRun?: (running: boolean) => void
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export class PageChannel {
|
|
@@ -68,7 +72,7 @@ export class PageChannel {
|
|
|
68
72
|
|
|
69
73
|
connect(): void {
|
|
70
74
|
if (this.disposed || this.source !== undefined) return
|
|
71
|
-
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)}`}`
|
|
72
76
|
const viaSocket = this.socketAllowed
|
|
73
77
|
this.socketReady = false
|
|
74
78
|
const source: ChannelSource = viaSocket
|
|
@@ -85,6 +89,10 @@ export class PageChannel {
|
|
|
85
89
|
heard()
|
|
86
90
|
})
|
|
87
91
|
source.addEventListener('ping', heard)
|
|
92
|
+
source.addEventListener('run', (event) => {
|
|
93
|
+
heard()
|
|
94
|
+
this.options.onRun?.(event.data === '1')
|
|
95
|
+
})
|
|
88
96
|
source.addEventListener('call', (event) => {
|
|
89
97
|
heard()
|
|
90
98
|
if (typeof event.data === 'string') this.answer(event.data)
|
|
@@ -158,6 +166,8 @@ export class PageChannel {
|
|
|
158
166
|
if (source !== undefined) {
|
|
159
167
|
source.onerror = null
|
|
160
168
|
source.close()
|
|
169
|
+
// Without the stream the page cannot hear the run end: the Host says again if it is still going, once the stream is back.
|
|
170
|
+
this.options.onRun?.(false)
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
173
|
|
|
@@ -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}` }
|
|
@@ -19,17 +19,24 @@ export const LEAVE_DELAY_MS = 150
|
|
|
19
19
|
export interface DriverActivity {
|
|
20
20
|
/** True from the moment a call starts until it ends. */
|
|
21
21
|
readonly busy: boolean
|
|
22
|
+
/** True from the moment a run asked for from this page's panel starts until it ends, between the calls too. */
|
|
23
|
+
readonly running: boolean
|
|
22
24
|
/** What the agent is doing, for the indicator ("click", "type", ...). */
|
|
23
25
|
readonly doing: string
|
|
24
26
|
/** True after the user pressed the kill switch. */
|
|
25
27
|
readonly killed: boolean
|
|
26
28
|
}
|
|
27
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
|
+
|
|
28
33
|
export interface UiDriverOptions {
|
|
29
34
|
readonly document: () => Document
|
|
30
35
|
/** Milliseconds since the user last used the page (mouse, keyboard); Infinity when never. */
|
|
31
36
|
readonly msSinceUserInput?: () => number
|
|
32
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
|
|
33
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. */
|
|
34
41
|
readonly leave?: (to: { readonly reload: true } | { readonly path: string }) => void
|
|
35
42
|
}
|
|
@@ -53,14 +60,20 @@ export class UiDriver {
|
|
|
53
60
|
private disposed = false
|
|
54
61
|
private busyCount = 0
|
|
55
62
|
private doing = ''
|
|
56
|
-
private current: DriverActivity = { busy: false, doing: '', killed: false }
|
|
63
|
+
private current: DriverActivity = { busy: false, running: false, doing: '', killed: false }
|
|
64
|
+
private runningFlag = false
|
|
57
65
|
|
|
58
66
|
private readonly options: UiDriverOptions
|
|
59
67
|
|
|
60
68
|
constructor(options: UiDriverOptions) {
|
|
61
69
|
this.options = options
|
|
62
70
|
this.snapshots = new SnapshotBuilder(this.refs, () => options.document().body)
|
|
63
|
-
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
|
+
}
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
/** The current activity; the same object until something changes, as a store snapshot must be. */
|
|
@@ -82,6 +95,17 @@ export class UiDriver {
|
|
|
82
95
|
this.emit()
|
|
83
96
|
}
|
|
84
97
|
|
|
98
|
+
/**
|
|
99
|
+
* A run asked for from this page's panel starts (`true`) or ends (`false`). The indicator stays up for the whole run, not only while a call is in flight. A new run is a new
|
|
100
|
+
* question from the person, so it also allows the agent to drive again after an earlier Stop.
|
|
101
|
+
*/
|
|
102
|
+
setRunning(running: boolean): void {
|
|
103
|
+
if (running === this.runningFlag) return
|
|
104
|
+
this.runningFlag = running
|
|
105
|
+
if (running) this.killedFlag = false
|
|
106
|
+
this.emit()
|
|
107
|
+
}
|
|
108
|
+
|
|
85
109
|
/** The user allowed the agent to drive again. */
|
|
86
110
|
resume(): void {
|
|
87
111
|
this.killedFlag = false
|
|
@@ -128,10 +152,10 @@ export class UiDriver {
|
|
|
128
152
|
private async run(request: UiRequest, signal: AbortSignal): Promise<UiResult> {
|
|
129
153
|
switch (request.op) {
|
|
130
154
|
case 'snapshot': return this.snapshots.snapshot({ ...(request.cursor === undefined ? {} : { cursor: request.cursor }), ...(request.maxNodes === undefined ? {} : { maxNodes: request.maxNodes }) })
|
|
131
|
-
case 'click': return click(this.env, request)
|
|
132
|
-
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)
|
|
133
157
|
case 'select': return selectOption(this.env, request)
|
|
134
|
-
case 'press': return pressKey(this.env, request)
|
|
158
|
+
case 'press': return await this.watchLeaving(() => pressKey(this.env, request), signal)
|
|
135
159
|
case 'scroll': return scroll(this.env, request)
|
|
136
160
|
case 'wait': return wait(this.env, request, signal)
|
|
137
161
|
case 'reload': return this.leave({ reload: true }, 'reloading')
|
|
@@ -139,6 +163,24 @@ export class UiDriver {
|
|
|
139
163
|
}
|
|
140
164
|
}
|
|
141
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
|
+
|
|
142
184
|
/**
|
|
143
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).
|
|
144
186
|
* The Host does not wait for this answer to be the last word: it waits for the page to come back.
|
|
@@ -165,8 +207,8 @@ export class UiDriver {
|
|
|
165
207
|
}
|
|
166
208
|
|
|
167
209
|
private emit(): void {
|
|
168
|
-
const next: DriverActivity = { busy: this.busyCount > 0, doing: this.doing, killed: this.killedFlag }
|
|
169
|
-
if (next.busy === this.current.busy && next.doing === this.current.doing && next.killed === this.current.killed) return
|
|
210
|
+
const next: DriverActivity = { busy: this.busyCount > 0, running: this.runningFlag, doing: this.doing, killed: this.killedFlag }
|
|
211
|
+
if (next.busy === this.current.busy && next.running === this.current.running && next.doing === this.current.doing && next.killed === this.current.killed) return
|
|
170
212
|
this.current = next
|
|
171
213
|
const activity = next
|
|
172
214
|
for (const listener of [...this.listeners]) listener(activity)
|
|
@@ -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
|