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.
Files changed (252) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +60 -2
  3. package/dist/index.js +1992 -33
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/api/summary.md +1 -1
  7. package/docs/framework/getting-started/desktop.md +23 -4
  8. package/docs/framework/guides/create-acrylblends.md +1 -1
  9. package/docs/framework/guides/production.md +20 -0
  10. package/docs/framework/workshop/README.md +174 -285
  11. package/docs/framework/workshop/facilitator.md +81 -90
  12. package/legacy/README.md +9 -0
  13. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  14. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  15. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  16. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  17. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  18. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  19. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  20. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  21. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  22. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  23. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  24. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  25. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  26. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  27. package/legacy/agent/855359304707/src/app.ts +168 -0
  28. package/legacy/agent/855359304707/src/routes.ts +126 -0
  29. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  30. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  31. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  32. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  33. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  34. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  35. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  36. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  37. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  38. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  39. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  40. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  41. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  42. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  43. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  44. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  45. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  46. package/legacy/layouts.json +524 -0
  47. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  48. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  49. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  50. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  51. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  52. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  53. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  54. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  55. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  56. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  57. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  58. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  59. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  60. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  61. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  62. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  63. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  64. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  65. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  66. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  67. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  68. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  69. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  70. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  71. package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
  72. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  73. package/lib/cordis/door.d.ts +44 -7
  74. package/lib/cordis/door.js +214 -60
  75. package/lib/cordis/inspect.js +1 -1
  76. package/lib/cordis/node-server.d.ts +101 -3
  77. package/lib/cordis/node-server.js +3 -3
  78. package/lib/cordis/router-http.d.ts +7 -1
  79. package/lib/cordis/router-http.js +44 -3
  80. package/lib/cordis/router.d.ts +1 -1
  81. package/lib/cordis/router.js +1 -1
  82. package/lib/cordis/state.d.ts +3 -2
  83. package/lib/cordis/state.js +371 -7
  84. package/lib/door/host.d.ts +2 -2
  85. package/lib/door/host.js +1 -1
  86. package/lib/door/page.d.ts +20 -1
  87. package/lib/door/page.js +75 -14
  88. package/lib/door/protocol.d.ts +1 -1
  89. package/lib/drivers.d.ts +10 -1
  90. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  91. package/lib/node-server.d.ts +7 -1
  92. package/lib/node-server.js +2 -2
  93. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  94. package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
  95. package/lib/src-CtkIX_Gg.js +427 -0
  96. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  97. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  98. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  99. package/package.json +2 -1
  100. package/plugins/acryl-door/src/host/channel.ts +124 -9
  101. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  102. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  103. package/plugins/acryl-door/src/host/door.ts +58 -23
  104. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  105. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  106. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  107. package/plugins/acryl-door/src/index.ts +166 -29
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +11 -1
  110. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  111. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  112. package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +46 -2
  115. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  116. package/plugins/acryl-door/src/page/outline.ts +56 -0
  117. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  118. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  119. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  120. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  121. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  122. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  123. package/plugins/acryl-node-server/src/index.ts +304 -16
  124. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  125. package/plugins/acryl-page/plugin.json +12 -0
  126. package/plugins/acryl-page/src/index.ts +163 -0
  127. package/plugins/acryl-router/src/service.ts +77 -14
  128. package/plugins/acryl-router-http/src/index.ts +15 -1
  129. package/plugins/acryl-router-http/src/serve.ts +31 -0
  130. package/plugins/acryl-state/plugin.json +4 -3
  131. package/plugins/acryl-state/src/client-source.ts +244 -0
  132. package/plugins/acryl-state/src/service.ts +33 -6
  133. package/plugins/acryl-state/src/socket.ts +96 -0
  134. package/plugins/acryl-web-server/plugin.json +14 -0
  135. package/plugins/acryl-web-server/src/index.ts +164 -0
  136. package/plugins/agent/agent.ts +38 -7
  137. package/plugins/agent/index.ts +1 -1
  138. package/plugins/agent/model.ts +10 -2
  139. package/plugins/agent/tools.ts +9 -3
  140. package/plugins/agent-panel/client/describe.ts +57 -0
  141. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  142. package/plugins/agent-panel/client/frame.ts +51 -0
  143. package/plugins/agent-panel/client/main.ts +343 -81
  144. package/plugins/agent-panel/drivers.ts +7 -2
  145. package/plugins/agent-panel/headless.ts +5 -2
  146. package/plugins/agent-panel/index.ts +187 -61
  147. package/plugins/agent-panel/mount.ts +27 -0
  148. package/plugins/agent-panel/visitors.ts +231 -0
  149. package/plugins/desktop/frame.ts +231 -0
  150. package/plugins/desktop/launch.ts +44 -9
  151. package/plugins/desktop/main.ts +100 -32
  152. package/plugins/desktop/window.ts +21 -4
  153. package/plugins/driver-pty/client/terminal.ts +4 -1
  154. package/plugins/driver-pty/index.ts +38 -11
  155. package/plugins/driver-pty/spawn.ts +38 -0
  156. package/plugins/driver-pty/terminal.ts +2 -0
  157. package/plugins/extensions/index.ts +26 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/feedback/client.d.ts +22 -0
  160. package/plugins/feedback/client.js +471 -0
  161. package/plugins/feedback/index.ts +143 -0
  162. package/plugins/feedback/plugin.json +11 -0
  163. package/plugins/feedback/store.ts +307 -0
  164. package/plugins/home/index.ts +122 -0
  165. package/plugins/home/plugin.json +11 -0
  166. package/plugins/instance/index.ts +12 -3
  167. package/plugins/manifest/app-plugins.ts +86 -0
  168. package/plugins/manifest/index.ts +12 -2
  169. package/plugins/manifest/snapshot.ts +275 -0
  170. package/plugins/openai/index.ts +64 -5
  171. package/plugins/openai/prompt.ts +23 -0
  172. package/shared/AGENTS.core.md +36 -16
  173. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  174. package/shared/skills/acryl-app/SKILL.md +13 -0
  175. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  176. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  177. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  178. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  179. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  180. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  181. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  182. package/templates/agent/AGENTS.md +4 -2
  183. package/templates/agent/blend.yaml +4 -0
  184. package/templates/agent/gitignore +1 -0
  185. package/templates/agent/package.json +1 -0
  186. package/templates/agent/src/app.ts +150 -33
  187. package/templates/agent/src/frame.ts +17 -0
  188. package/templates/agent/src/main.ts +5 -3
  189. package/templates/agent/src/routes.ts +73 -21
  190. package/templates/agent/template.json +2 -0
  191. package/templates/agent/test/app.test.ts +83 -5
  192. package/templates/blank/AGENTS.md +3 -2
  193. package/templates/blank/blend.yaml +11 -0
  194. package/templates/blank/gitignore +1 -0
  195. package/templates/blank/package.json +1 -0
  196. package/templates/blank/src/app.ts +100 -29
  197. package/templates/blank/src/door.ts +7 -0
  198. package/templates/blank/src/first-page.ts +5 -0
  199. package/templates/blank/src/frame.ts +17 -0
  200. package/templates/blank/src/routes.ts +65 -8
  201. package/templates/blank/template.json +14 -1
  202. package/templates/blank/test/app.test.ts +103 -16
  203. package/templates/chat/blend.yaml +2 -1
  204. package/templates/chat/gitignore +1 -0
  205. package/templates/chat/package.json +1 -0
  206. package/templates/chat/src/app.ts +72 -22
  207. package/templates/chat/src/chat-app.ts +11 -2
  208. package/templates/chat/src/client/chat.tsx +4 -2
  209. package/templates/chat/src/frame.ts +17 -0
  210. package/templates/chat/src/main.ts +1 -6
  211. package/templates/chat/src/page.tsx +6 -4
  212. package/templates/chat/src/routes.ts +14 -4
  213. package/templates/chat/src/runtime.ts +9 -3
  214. package/templates/chat/template.json +3 -1
  215. package/templates/chat/test/chat.test.ts +46 -1
  216. package/templates/chat/test/server.test.ts +17 -3
  217. package/templates/nodejs/blend.yaml +2 -1
  218. package/templates/nodejs/gitignore +1 -0
  219. package/templates/nodejs/package.json +1 -0
  220. package/templates/nodejs/src/app.ts +76 -21
  221. package/templates/nodejs/src/frame.ts +17 -0
  222. package/templates/nodejs/src/main.ts +1 -6
  223. package/templates/nodejs/src/page.tsx +3 -3
  224. package/templates/nodejs/src/routes.ts +32 -13
  225. package/templates/nodejs/template.json +3 -1
  226. package/templates/nodejs/test/app.test.ts +38 -3
  227. package/templates/nodejs/test/server.test.ts +17 -3
  228. package/templates/notes/blend.yaml +2 -1
  229. package/templates/notes/gitignore +1 -0
  230. package/templates/notes/package.json +1 -0
  231. package/templates/notes/src/app.ts +72 -22
  232. package/templates/notes/src/frame.ts +17 -0
  233. package/templates/notes/src/main.ts +1 -6
  234. package/templates/notes/src/notes-app.ts +24 -17
  235. package/templates/notes/src/routes.ts +11 -4
  236. package/templates/notes/src/runtime.ts +9 -3
  237. package/templates/notes/src/views.tsx +26 -17
  238. package/templates/notes/template.json +3 -1
  239. package/templates/notes/test/notes.test.ts +39 -1
  240. package/templates/notes/test/server.test.ts +17 -3
  241. package/templates/todo/blend.yaml +3 -0
  242. package/templates/todo/gitignore +1 -0
  243. package/templates/todo/package.json +1 -0
  244. package/templates/todo/src/app.ts +81 -27
  245. package/templates/todo/src/door.ts +7 -0
  246. package/templates/todo/src/frame.ts +17 -0
  247. package/templates/todo/src/page.ts +13 -6
  248. package/templates/todo/src/todo.ts +18 -6
  249. package/templates/todo/template.json +11 -1
  250. package/templates/todo/test/todo.test.ts +67 -9
  251. package/lib/src-LX0E4ec4.js +0 -117
  252. 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 part of `acryl-node-server` the door reads: where the app listens. */
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 url: string
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
- export const inject = ['router', 'appInstance', 'nodeServer']
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
- if (process.env.ACRYL_DOOR === 'off' || (process.env.NODE_ENV === 'production' && config.allowInProduction !== true)) return
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
- const server = ctx.get('nodeServer') as unknown as Listening
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
- const channel = new UiChannel({ attachWaitMs: 2000 })
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
- app: { name: app.name, root: app.root, url: loopbackUrl(server.url) },
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 (the templates' own pages) is left as it is. Development only, like the rest of the door.
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
- const html = await c.res.text()
208
+ let html = await c.res.text()
151
209
  const headers = new Headers(c.res.headers)
152
210
  headers.delete('content-length')
153
- const carries = html.includes(`${DOOR_PREFIX}/page.js`)
154
- c.res = new Response(carries ? html : html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag, { status: c.res.status, headers })
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
- // The page channel also runs over a WebSocket, which is not one of the browser's six connections per host. Without the `ws` package or an adapter that upgrades, the pages use the stream.
161
- let upgrade: UpgradeWebSocket | undefined
162
- try {
163
- upgrade = (ctx as unknown as { nodeServer?: { upgrades?: () => UpgradeWebSocket } }).nodeServer?.upgrades?.()
164
- } catch (cause) {
165
- reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
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
- ...(upgrade === undefined ? [] : [pageSocketRoutes(channel, { prefix: DOOR_PREFIX, upgrade })]),
172
- shellRoutes(DOOR_PREFIX, secret, shell),
173
- operatorRoutes(DOOR_PREFIX, secret, door, reportError),
174
- mcpRoutes(DOOR_PREFIX, secret, door, reportError),
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
- writeDoorFile(app.root, loopbackUrl(server.url), secret)
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
- yield () => { removeDoorFile(app.root, secret) }
186
- }, 'acryl-door: the door service, .acryl/door.json and .acryl/bin/acryl')
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 = { refs: this.refs, document: options.document, sleep: options.sleep ?? realSleep }
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
- if (options.cursor === undefined) {
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