acrylblends 1.1.0 → 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 (241) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +45 -1
  3. package/dist/index.js +1980 -27
  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 +1 -1
  8. package/docs/framework/workshop/README.md +138 -152
  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 +42 -7
  72. package/lib/cordis/door.js +204 -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/src/host/channel.ts +86 -9
  98. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  99. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  100. package/plugins/acryl-door/src/host/door.ts +53 -23
  101. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  102. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  103. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  104. package/plugins/acryl-door/src/index.ts +166 -29
  105. package/plugins/acryl-door/src/page/base.ts +32 -0
  106. package/plugins/acryl-door/src/page/channel.ts +3 -1
  107. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  108. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  109. package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
  110. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  111. package/plugins/acryl-door/src/page/index.ts +41 -2
  112. package/plugins/acryl-door/src/page/outline.ts +56 -0
  113. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  114. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  115. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  116. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  117. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  118. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  119. package/plugins/acryl-node-server/src/index.ts +304 -16
  120. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  121. package/plugins/acryl-page/plugin.json +12 -0
  122. package/plugins/acryl-page/src/index.ts +163 -0
  123. package/plugins/acryl-router/src/service.ts +77 -14
  124. package/plugins/acryl-router-http/src/index.ts +15 -1
  125. package/plugins/acryl-router-http/src/serve.ts +31 -0
  126. package/plugins/acryl-web-server/plugin.json +14 -0
  127. package/plugins/acryl-web-server/src/index.ts +164 -0
  128. package/plugins/agent/agent.ts +38 -7
  129. package/plugins/agent/index.ts +1 -1
  130. package/plugins/agent/model.ts +10 -2
  131. package/plugins/agent/tools.ts +9 -3
  132. package/plugins/agent-panel/client/describe.ts +57 -0
  133. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  134. package/plugins/agent-panel/client/frame.ts +51 -0
  135. package/plugins/agent-panel/client/main.ts +238 -26
  136. package/plugins/agent-panel/drivers.ts +7 -2
  137. package/plugins/agent-panel/headless.ts +5 -2
  138. package/plugins/agent-panel/index.ts +146 -57
  139. package/plugins/agent-panel/mount.ts +27 -0
  140. package/plugins/agent-panel/visitors.ts +231 -0
  141. package/plugins/desktop/frame.ts +231 -0
  142. package/plugins/desktop/launch.ts +44 -9
  143. package/plugins/desktop/main.ts +100 -32
  144. package/plugins/desktop/window.ts +21 -4
  145. package/plugins/driver-pty/client/terminal.ts +4 -1
  146. package/plugins/driver-pty/index.ts +38 -11
  147. package/plugins/driver-pty/spawn.ts +38 -0
  148. package/plugins/driver-pty/terminal.ts +2 -0
  149. package/plugins/extensions/index.ts +26 -1
  150. package/plugins/extensions/resolve.ts +13 -0
  151. package/plugins/feedback/client.d.ts +22 -0
  152. package/plugins/feedback/client.js +471 -0
  153. package/plugins/feedback/index.ts +143 -0
  154. package/plugins/feedback/plugin.json +11 -0
  155. package/plugins/feedback/store.ts +307 -0
  156. package/plugins/home/index.ts +122 -0
  157. package/plugins/home/plugin.json +11 -0
  158. package/plugins/instance/index.ts +6 -0
  159. package/plugins/manifest/app-plugins.ts +86 -0
  160. package/plugins/manifest/index.ts +12 -2
  161. package/plugins/manifest/snapshot.ts +275 -0
  162. package/plugins/openai/index.ts +64 -5
  163. package/plugins/openai/prompt.ts +23 -0
  164. package/shared/AGENTS.core.md +36 -16
  165. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  166. package/shared/skills/acryl-app/SKILL.md +13 -0
  167. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  168. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  169. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  170. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  171. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  172. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  173. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  174. package/templates/agent/AGENTS.md +1 -1
  175. package/templates/agent/blend.yaml +4 -0
  176. package/templates/agent/gitignore +1 -0
  177. package/templates/agent/package.json +1 -0
  178. package/templates/agent/src/app.ts +150 -33
  179. package/templates/agent/src/frame.ts +17 -0
  180. package/templates/agent/src/main.ts +5 -3
  181. package/templates/agent/src/routes.ts +38 -19
  182. package/templates/agent/template.json +2 -0
  183. package/templates/agent/test/app.test.ts +45 -3
  184. package/templates/blank/AGENTS.md +3 -2
  185. package/templates/blank/blend.yaml +11 -0
  186. package/templates/blank/gitignore +1 -0
  187. package/templates/blank/package.json +1 -0
  188. package/templates/blank/src/app.ts +100 -29
  189. package/templates/blank/src/door.ts +7 -0
  190. package/templates/blank/src/first-page.ts +5 -0
  191. package/templates/blank/src/frame.ts +17 -0
  192. package/templates/blank/src/routes.ts +65 -8
  193. package/templates/blank/template.json +14 -1
  194. package/templates/blank/test/app.test.ts +103 -16
  195. package/templates/chat/blend.yaml +2 -1
  196. package/templates/chat/gitignore +1 -0
  197. package/templates/chat/package.json +1 -0
  198. package/templates/chat/src/app.ts +72 -22
  199. package/templates/chat/src/chat-app.ts +11 -2
  200. package/templates/chat/src/client/chat.tsx +4 -2
  201. package/templates/chat/src/frame.ts +17 -0
  202. package/templates/chat/src/page.tsx +6 -4
  203. package/templates/chat/src/routes.ts +14 -4
  204. package/templates/chat/src/runtime.ts +9 -3
  205. package/templates/chat/template.json +3 -1
  206. package/templates/chat/test/chat.test.ts +46 -1
  207. package/templates/chat/test/server.test.ts +17 -3
  208. package/templates/nodejs/blend.yaml +2 -1
  209. package/templates/nodejs/gitignore +1 -0
  210. package/templates/nodejs/package.json +1 -0
  211. package/templates/nodejs/src/app.ts +76 -21
  212. package/templates/nodejs/src/frame.ts +17 -0
  213. package/templates/nodejs/src/page.tsx +3 -3
  214. package/templates/nodejs/src/routes.ts +32 -13
  215. package/templates/nodejs/template.json +3 -1
  216. package/templates/nodejs/test/app.test.ts +38 -3
  217. package/templates/nodejs/test/server.test.ts +17 -3
  218. package/templates/notes/blend.yaml +2 -1
  219. package/templates/notes/gitignore +1 -0
  220. package/templates/notes/package.json +1 -0
  221. package/templates/notes/src/app.ts +72 -22
  222. package/templates/notes/src/frame.ts +17 -0
  223. package/templates/notes/src/notes-app.ts +24 -17
  224. package/templates/notes/src/routes.ts +11 -4
  225. package/templates/notes/src/runtime.ts +9 -3
  226. package/templates/notes/src/views.tsx +26 -17
  227. package/templates/notes/template.json +3 -1
  228. package/templates/notes/test/notes.test.ts +39 -1
  229. package/templates/notes/test/server.test.ts +17 -3
  230. package/templates/todo/blend.yaml +3 -0
  231. package/templates/todo/gitignore +1 -0
  232. package/templates/todo/package.json +1 -0
  233. package/templates/todo/src/app.ts +81 -27
  234. package/templates/todo/src/door.ts +7 -0
  235. package/templates/todo/src/frame.ts +17 -0
  236. package/templates/todo/src/page.ts +13 -6
  237. package/templates/todo/src/todo.ts +18 -6
  238. package/templates/todo/template.json +11 -1
  239. package/templates/todo/test/todo.test.ts +67 -9
  240. package/lib/src-LX0E4ec4.js +0 -117
  241. package/shared/SKILL.md +0 -14
@@ -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
+ }
@@ -60,6 +60,8 @@ export interface UiNode {
60
60
  readonly value?: string
61
61
  /** Heading level, when it is a heading. */
62
62
  readonly level?: number
63
+ /** The choices of a select, as `act select` takes them, when it is not sensitive. */
64
+ readonly options?: readonly string[]
63
65
  }
64
66
 
65
67
  export interface UiSnapshot {
@@ -88,6 +90,8 @@ export interface UiActionResult {
88
90
  readonly ok: true
89
91
  readonly target?: { readonly role: string; readonly name: string }
90
92
  readonly detail?: string
93
+ /** The act started a navigation (a link, a form that submits): this page is going away, and the Host waits for the next one. */
94
+ readonly leaving?: true
91
95
  }
92
96
 
93
97
  export type UiResult = UiSnapshot | UiActionResult
@@ -11,7 +11,8 @@ export function renderSnapshot(snapshot: UiSnapshot, more: (cursor: number) => s
11
11
  const level = node.level === undefined ? '' : ` level=${String(node.level)}`
12
12
  const states = node.states.length === 0 ? '' : ` [${node.states.join(', ')}]`
13
13
  const value = node.value === undefined ? '' : ` value=${JSON.stringify(node.value)}`
14
- return `${' '.repeat(Math.min(node.depth, 8))}- ${node.role}${node.name === '' ? '' : ` ${JSON.stringify(node.name)}`}${level}${states}${value} [ref=${node.ref}]`
14
+ const options = node.options === undefined ? '' : ` options=${JSON.stringify(node.options)}`
15
+ return `${' '.repeat(Math.min(node.depth, 8))}- ${node.role}${node.name === '' ? '' : ` ${JSON.stringify(node.name)}`}${level}${states}${value}${options} [ref=${node.ref}]`
15
16
  })
16
17
  const rest = snapshot.nextCursor === undefined ? '' : `\n(${String(snapshot.total - snapshot.nextCursor)} more elements; ${more(snapshot.nextCursor)})`
17
18
  return `Page: ${snapshot.title || '(untitled)'}\nSnapshot ${String(snapshot.generation)}, ${String(snapshot.nodes.length)} of ${String(snapshot.total)} elements:\n${lines.join('\n')}${rest}`
@@ -14,6 +14,7 @@ import {
14
14
  } from './request.ts'
15
15
  import { defaultContentType, cacheKey, Response as LightweightResponse } from './response.ts'
16
16
  import type { InternalCache } from './response.ts'
17
+ import { RAW_RESPONSE_HANDLED } from './raw.ts'
17
18
  import type { CustomErrorHandler, FetchCallback, HttpBindings } from './types.ts'
18
19
  import {
19
20
  readWithoutBlocking,
@@ -500,6 +501,9 @@ export const getRequestListener = (
500
501
  res = fetchCallback(req, { incoming, outgoing } as HttpBindings) as
501
502
  | Response
502
503
  | Promise<Response>
504
+ | typeof RAW_RESPONSE_HANDLED
505
+ // A raw handler (not a `Response`) owns the node response: it has written (or is writing) it, so the adapter writes nothing.
506
+ if (res === RAW_RESPONSE_HANDLED) return
503
507
  if (!isPromise(res) && isImmediateCacheableResponse(res)) {
504
508
  // Synchronous cacheable response — no close listener needed.
505
509
  // No I/O events can fire between fetchCallback returning and responseViaCache
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Raw HTTP handlers, outside the router: a host that must serve another server's node `(req, res)` handlers (a DSH compatibility port, the
3
+ * desktop shell's own endpoints) registers by path instead of building an `Acryl`. The adapter hands a matched request to the handler with the
4
+ * real `IncomingMessage`/`ServerResponse` and writes nothing itself; `RAW_RESPONSE_HANDLED` is how the fetch callback tells it so.
5
+ *
6
+ * Upgrades work the same way at the socket level: a registered path is claimed before the router's WebSocket handling, and the adapter's
7
+ * WebSocket setup leaves a request marked with `RAW_UPGRADE_HANDLED` alone.
8
+ */
9
+ import type { IncomingMessage } from 'node:http'
10
+
11
+ /** Returned by a raw-aware fetch callback: the node response is already owned, do not write. */
12
+ export const RAW_RESPONSE_HANDLED: unique symbol = Symbol.for('acryl-node-server.raw-response')
13
+
14
+ /** Set on an upgrade request a raw handler took over; the adapter's WebSocket setup skips it. */
15
+ export const RAW_UPGRADE_HANDLED: unique symbol = Symbol.for('acryl-node-server.raw-upgrade')
16
+
17
+ /** An upgrade request a raw handler took over. */
18
+ export type RawUpgradeRequest = IncomingMessage & { [RAW_UPGRADE_HANDLED]?: true }
@@ -3,6 +3,7 @@ import { defineWebSocketHelper } from 'acryl-router/helpers'
3
3
  import type { IncomingMessage } from 'node:http'
4
4
  import { STATUS_CODES } from 'node:http'
5
5
  import type { Duplex } from 'node:stream'
6
+ import { RAW_UPGRADE_HANDLED, type RawUpgradeRequest } from './raw.ts'
6
7
  import type { FetchCallback } from './types.ts'
7
8
  import type { WebSocketData, WebSocketLike, WebSocketServerLike } from './websocket-types.ts'
8
9
 
@@ -144,6 +145,8 @@ const appendResponseHeaders = (headers: string[], responseHeaders?: Headers) =>
144
145
  }
145
146
 
146
147
  const rejectUpgradeRequest = (socket: Duplex, status: number, responseHeaders?: Headers) => {
148
+ // The socket already has its answer (a provider transition can route one upgrade to two handlers for a moment): never write a second one.
149
+ if (socket.destroyed || socket.writableEnded) return
147
150
  const responseLines = ['Connection: close', 'Content-Length: 0']
148
151
  appendResponseHeaders(responseLines, responseHeaders)
149
152
 
@@ -181,8 +184,13 @@ export const setupWebSocket = (options: {
181
184
  server: UpgradeSource
182
185
  fetchCallback: FetchCallback
183
186
  wss: WebSocketServerLike
187
+ /**
188
+ * Leave a request alone when a raw upgrade handler claimed it. Set by a transport that shares its upgrade source with raw claims (the node
189
+ * server's HTTP server); a hub fed by its own emitter (the Web adapter) must still answer its own upgrades, which arrive through a raw claim.
190
+ */
191
+ skipWhenRawUpgrade?: boolean
184
192
  }): void => {
185
- const { server, fetchCallback, wss } = options
193
+ const { server, fetchCallback, wss, skipWhenRawUpgrade = false } = options
186
194
 
187
195
  const waiterMap = new Map<
188
196
  IncomingMessage,
@@ -216,9 +224,11 @@ export const setupWebSocket = (options: {
216
224
  }
217
225
 
218
226
  server.on('upgrade', async (request, socket: Duplex, head) => {
227
+ // A raw upgrade handler claimed this path before the WebSocket setup ran: the socket is (or will be) its own.
228
+ if (skipWhenRawUpgrade && (request as RawUpgradeRequest)[RAW_UPGRADE_HANDLED] === true) return
219
229
  if (request.headers.upgrade?.toLowerCase() !== 'websocket') {
220
- // Nobody else listens: an upgrade to another protocol is answered, not left hanging.
221
- if (server.listenerCount('upgrade') === 1) rejectUpgradeRequest(socket, 400)
230
+ // An upgrade to another protocol is answered, not left hanging (the transport's raw listener flags the ones it already answered).
231
+ rejectUpgradeRequest(socket, 400)
222
232
  return
223
233
  }
224
234
 
@@ -241,9 +251,7 @@ export const setupWebSocket = (options: {
241
251
  responseHeaders = response.headers
242
252
  }
243
253
  } catch {
244
- if (server.listenerCount('upgrade') === 1) {
245
- rejectUpgradeRequest(socket, 500)
246
- }
254
+ rejectUpgradeRequest(socket, 500)
247
255
  return
248
256
  }
249
257
 
@@ -251,9 +259,7 @@ export const setupWebSocket = (options: {
251
259
 
252
260
  if (!waiter || waiter.connectionSymbol !== env[CONNECTION_SYMBOL_KEY]) {
253
261
  rejectWaiter(request)
254
- if (server.listenerCount('upgrade') === 1) {
255
- rejectUpgradeRequest(socket, status, responseHeaders)
256
- }
262
+ rejectUpgradeRequest(socket, status, responseHeaders)
257
263
  return
258
264
  }
259
265
 
@@ -38,6 +38,12 @@ const DEFAULT_CACHE_BYTES = 16 * 1024 * 1024
38
38
  export interface ClientModulesOptions {
39
39
  /** The folder of client modules, absolute or relative to the working directory. */
40
40
  readonly root: string
41
+ /**
42
+ * The URL prefix the app is mounted at, when the caller knows it. A host can compose app plugins under its own router, and under a
43
+ * Host whose router is a different copy of this package the request's match result is not visible here, so `basePath(c)` throws; the
44
+ * explicit prefix is then the only reliable way to know where the mount starts. Omitted, the router's `basePath(c)` is used.
45
+ */
46
+ readonly mount?: string | undefined
41
47
  /** The folder {@link buildClientModules} wrote; with `NODE_ENV=production` it is served instead of compiling, which needs no `typescript`. */
42
48
  readonly compiled?: string | undefined
43
49
  /** At most this many bytes of compiled modules are kept in memory (least recently served first out); 16 MiB when not given. */
@@ -204,7 +210,7 @@ export function clientModules(options: ClientModulesOptions): Acryl {
204
210
 
205
211
  const app = new Acryl()
206
212
  app.get('/*', (c) => {
207
- const requestPath = c.req.path.slice(basePath(c).length)
213
+ const requestPath = c.req.path.slice(options.mount === undefined ? basePath(c).length : options.mount.length)
208
214
  const runtime = /^\/@acrylblends\/([a-z-]+\.js)$/u.exec(requestPath)?.[1]
209
215
  const send = (code: string, etag: string): Response => {
210
216
  if (c.req.header('if-none-match') === etag) return c.body(null, 304, { etag, 'cache-control': 'no-cache' })