acrylblends 1.1.0 → 1.3.0

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