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
@@ -4,20 +4,62 @@
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'
13
15
 
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. */
17
+ export const PAGE_KEY = Symbol.for('acryl.door.page')
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
+
14
39
  /** Start the door on `document`. @returns the disposer. */
15
40
  export function startDoor(document: Document, base: string = DOOR_BASE): () => void {
16
41
  const view = document.defaultView
17
42
  if (view === null) throw new Error('acryl-door: the document has no window')
18
43
  const clock = new UserInputClock()
19
- 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
+ })
20
60
  const windowId = typeof view.crypto?.randomUUID === 'function' ? view.crypto.randomUUID() : `w-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`
61
+ Object.defineProperty(view, PAGE_KEY, { value: Object.freeze({ windowId }), configurable: true })
62
+ const tab = tabOf(view)
21
63
  const focused = (): boolean => document.visibilityState === 'visible' && document.hasFocus()
22
64
  const detachInput = clock.attach(document)
23
65
  const removeIndicator = mountIndicator(driver, document)
@@ -30,7 +72,7 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
30
72
  return 'auto'
31
73
  }
32
74
  })()
33
- const channel = new PageChannel(driver, { windowId, focused, base, transport, ...(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 } : {}) })
34
76
  const reportFocus = (): void => { channel.reportFocus(focused()) }
35
77
  view.addEventListener('focus', reportFocus)
36
78
  view.addEventListener('blur', reportFocus)
@@ -47,8 +89,10 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
47
89
  view.removeEventListener('pagehide', leave)
48
90
  view.removeEventListener('pageshow', back)
49
91
  document.removeEventListener('visibilitychange', reportFocus)
92
+ Reflect.deleteProperty(view, PAGE_KEY)
50
93
  channel.dispose()
51
94
  removeIndicator()
95
+ outlines.dispose()
52
96
  detachInput()
53
97
  // Pending calls settle as `unloaded` and every ref goes stale.
54
98
  driver.dispose()
@@ -1,6 +1,7 @@
1
1
  /**
2
- * "The agent is driving" bar with its kill switch, in plain DOM (the React one in `acryl-agent-control` shows the same states). Shown while
3
- * a call runs and after the person stopped the agent. It is marked as driver UI (so snapshots skip it) and as off limits to the agent (so
2
+ * "The agent is driving" bar with its kill switch, in plain DOM (the React one in `acryl-agent-control` shows the same states). Shown for the whole of a run asked for from
3
+ * this page's panel (between the calls too), while a call runs, and after the person stopped the agent. Stop kills the page's control and asks the panel to stop the run (the
4
+ * `acryl:stop-run` event on the window, which the panel listens for). It is marked as driver UI (so snapshots skip it) and as off limits to the agent (so
4
5
  * it can never press its own Stop or Allow buttons). Escape stops the agent too, while it is working.
5
6
  */
6
7
 
@@ -8,6 +9,9 @@ import type { DriverActivity, UiDriver } from './driver/driver.ts'
8
9
 
9
10
  const LABELS: Record<string, string> = { snapshot: 'looking at the page', click: 'clicking', type: 'typing', select: 'choosing an option', press: 'pressing a key', scroll: 'scrolling', wait: 'waiting' }
10
11
 
12
+ /** Dispatched on the page's window by the Stop button: the agent panel in the same page cancels its run. */
13
+ export const STOP_RUN_EVENT = 'acryl:stop-run'
14
+
11
15
  const STYLE_ID = 'acryl-door-indicator-style'
12
16
  const STYLES = `
13
17
  .acrylDoorBar { position: fixed; z-index: 2147483647; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px;
@@ -32,13 +36,17 @@ export function mountIndicator(driver: UiDriver, document: Document): () => void
32
36
  bar.setAttribute('role', 'status')
33
37
  bar.setAttribute('aria-live', 'polite')
34
38
 
39
+ const stopAgent = (): void => {
40
+ driver.kill()
41
+ document.defaultView?.dispatchEvent(new CustomEvent(STOP_RUN_EVENT))
42
+ }
35
43
  const onKey = (event: KeyboardEvent): void => {
36
- if (event.key === 'Escape' && event.isTrusted && driver.activity.busy) driver.kill()
44
+ if (event.key === 'Escape' && event.isTrusted && (driver.activity.busy || driver.activity.running)) stopAgent()
37
45
  }
38
46
  document.addEventListener('keydown', onKey, true)
39
47
 
40
48
  const render = (activity: DriverActivity): void => {
41
- if (!activity.busy && !activity.killed) { bar.remove(); return }
49
+ if (!activity.busy && !activity.running && !activity.killed) { bar.remove(); return }
42
50
  const button = document.createElement('button')
43
51
  button.type = 'button'
44
52
  const text = document.createElement('span')
@@ -53,9 +61,9 @@ export function mountIndicator(driver: UiDriver, document: Document): () => void
53
61
  const dot = document.createElement('span')
54
62
  dot.className = 'acrylDoorDot'
55
63
  dot.setAttribute('aria-hidden', 'true')
56
- text.textContent = `Agent is driving: ${LABELS[activity.doing] ?? activity.doing}`
64
+ text.textContent = `Agent is driving: ${activity.busy ? (LABELS[activity.doing] ?? activity.doing) : 'working on your request'}`
57
65
  button.textContent = 'Stop'
58
- button.addEventListener('click', () => { driver.kill() })
66
+ button.addEventListener('click', stopAgent)
59
67
  bar.replaceChildren(dot, text, button)
60
68
  }
61
69
  if (!bar.isConnected) document.body.append(bar)
@@ -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' })