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
@@ -1,13 +1,69 @@
1
1
  // The panel in the page: a button at the corner opens a side panel (a shadow root, so the app's CSS cannot touch it and it cannot touch the app's). It asks the app's
2
- // agent through POST /api/agent-panel/ask, shows the answer as it arrives, and reloads the page after a run that installed or removed a plugin.
3
- import { describeTool } from './describe.ts'
2
+ // agent through POST <prefix>/ask, shows the answer as it arrives, and reloads the page after a run that installed or removed a plugin. `panel.js` calls
3
+ // `start` with the prefix the app is served under (the root of its own server, or `/app` when the ACRYL frame hosts it).
4
+ import { describePageStep, describeTool } from './describe.ts'
5
+ import { createPickGeneration } from './driver-pick.ts'
6
+ import { FRAME_MODE_EVENT, frameViewOf, panelVariant, panelVisible } from './frame.ts'
4
7
  import { renderMarkdown } from './markdown.ts'
5
8
 
6
- const PREFIX = '/api/agent-panel'
9
+ let PREFIX = '/api/agent-panel'
10
+ /** Where the composer's draft is kept across a page reload or a remount. */
11
+ const DRAFT_KEY = 'agent-panel-draft'
12
+ /** Set when a visitor closed the Assistant, so it stays closed on the next page. */
13
+ const ASSISTANT_CLOSED_KEY = 'agent-panel-assistant-closed'
14
+ /** What the Assistant tells a visitor first: what it can do, and that it cannot change the app. */
15
+ export const ASSISTANT_NOTE =
16
+ 'The assistant can use the app for you: open pages, filter, fill in forms. It cannot change the app.'
17
+
18
+ /**
19
+ * Why a submit does not send: nothing typed, or a run still going. The panel says it in its log instead of returning silently - a Send that does nothing and
20
+ * says nothing reads as a broken button, and one was observed doing exactly that in the frame gate (empty log, enabled Send, no run).
21
+ */
22
+ export type SendRefusal = 'empty' | 'busy'
23
+ export function sendRefusal(message: string, following: boolean): SendRefusal | undefined {
24
+ if (message.trim() === '') return 'empty'
25
+ if (following) return 'busy'
26
+ return undefined
27
+ }
28
+
29
+ /**
30
+ * Whether the page may be replaced to show a change a run made. Asked at the moment of reloading, not earlier: text in the composer or the focus in it
31
+ * belongs to the person, and a run still going must not lose its page either.
32
+ */
33
+ export function reloadDecision(state: {
34
+ readonly value: string
35
+ readonly focused: boolean
36
+ readonly following: boolean
37
+ }): boolean {
38
+ return state.value.trim() === '' && !state.focused && !state.following
39
+ }
40
+
41
+ /** Where the last panel-initiated reload is recorded, for the page after it and for a gate looking at a failure. */
42
+ export const RELOAD_MARKER = 'agent-panel-reload'
43
+
44
+ /** Replace the page, saying why first: a warning the page's own error report and the frame gate both see, and a marker the reloaded page keeps. */
45
+ function reloadPage(reason: string): void {
46
+ try {
47
+ sessionStorage.setItem(RELOAD_MARKER, JSON.stringify({ reason, at: Date.now() }))
48
+ } catch {
49
+ // No storage: the warning below still names it.
50
+ }
51
+ console.warn(`agent-panel: reloading the page (${reason})`)
52
+ location.reload()
53
+ }
7
54
 
8
55
  interface Session {
9
56
  token: string
10
- drivers: Array<{ name: string; kind: 'chat' | 'terminal' }>
57
+ /** The production Assistant: the app's own agent drives the page for a visitor. */
58
+ assistant?: boolean
59
+ /** The app's production environment names the Assistant (ACRYL_ASSISTANT=on); the frame's Preview shows it even though the frame host runs in development. */
60
+ assistantInProduction?: boolean
61
+ drivers: Array<{
62
+ name: string
63
+ kind: 'chat' | 'terminal'
64
+ /** A terminal driver says where its view module is and which prefix its endpoints live under (the driver's own plugin serves both). */
65
+ view?: { module: string; prefix: string }
66
+ }>
11
67
  }
12
68
  interface PanelEvent {
13
69
  event: string
@@ -42,70 +98,90 @@ interface RunSource {
42
98
  readonly events: AsyncIterable<PanelEvent>
43
99
  }
44
100
 
101
+ /** The door's id for this page, when the door is in it: sent with a question so the agent's calls (which name no window) drive this page and not another open one. */
102
+ function doorPage(): { page?: string } {
103
+ const published = (window as unknown as Record<symbol, { windowId?: unknown } | undefined>)[
104
+ Symbol.for('acryl.door.page')
105
+ ]
106
+ return typeof published?.windowId === 'string' ? { page: published.windowId } : {}
107
+ }
108
+
45
109
  /** A WebSocket is not one of the browser's six connections per host, so the panel's Stop and a run still work when two windows stream their own state. */
46
110
  let socketBroken = false
47
111
  let activeSocket: WebSocket | undefined
48
112
 
49
- /** Open the run socket and send the token and the first action. Rejects when the page cannot upgrade (a proxy, a server without `ws`): the caller falls back to the stream. */
50
- function openSocket(token: string, action: Record<string, unknown>): Promise<WebSocket> {
113
+ /** The server did not accept the token: it is not the one this page was given (the app restarted since, which `node --watch` does at every change of a file). */
114
+ class Refused extends Error {}
115
+
116
+ /**
117
+ * Open the run socket and send the token and the first action. Resolves once the server has accepted the token (its `accepted` frame), with the socket and its events: the
118
+ * frames of the run as panel events, until the socket closes. One set of handlers serves the socket for its whole life, so a frame is never handed to a handler that was
119
+ * replaced. Rejects with `Refused` when the token is not accepted, and with another error when the page cannot upgrade (a proxy, a server without `ws`): the caller falls back
120
+ * to the stream.
121
+ */
122
+ function openSocket(
123
+ token: string,
124
+ action: Record<string, unknown>,
125
+ ): Promise<{ socket: WebSocket; events: AsyncGenerator<PanelEvent> }> {
51
126
  return new Promise((resolve, reject) => {
52
127
  const socket = new WebSocket(
53
128
  `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${PREFIX}/run/ws`,
54
129
  )
130
+ const queue: PanelEvent[] = []
131
+ let wake: (() => void) | undefined
132
+ let closed = false
133
+ let accepted = false
55
134
  const timer = setTimeout(() => {
56
135
  socket.close()
57
- reject(new Error('the socket did not open in time'))
136
+ reject(new Error('the socket was not accepted in time'))
58
137
  }, 4000)
59
138
  socket.onopen = () => {
60
- clearTimeout(timer)
61
139
  socket.send(JSON.stringify({ t: 'hello', token }))
62
140
  socket.send(JSON.stringify(action))
63
- resolve(socket)
64
141
  }
65
- socket.onerror = () => {
66
- clearTimeout(timer)
67
- reject(new Error('the server refused the upgrade'))
142
+ socket.onmessage = (message) => {
143
+ try {
144
+ const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
145
+ if (frame.e === 'accepted' && !accepted) {
146
+ accepted = true
147
+ clearTimeout(timer)
148
+ resolve({ socket, events: read() })
149
+ } else if (typeof frame.e === 'string')
150
+ queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
151
+ } catch {
152
+ // Not a frame of ours.
153
+ }
154
+ wake?.()
68
155
  }
69
- })
70
- }
71
-
72
- /** The frames of an open run socket as panel events, until it closes. */
73
- async function* socketEvents(socket: WebSocket): AsyncGenerator<PanelEvent> {
74
- const queue: PanelEvent[] = []
75
- let wake: (() => void) | undefined
76
- let closed: CloseEvent | undefined
77
- socket.onmessage = (message) => {
78
- try {
79
- const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
80
- if (typeof frame.e === 'string')
81
- queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
82
- } catch {
83
- // Not a frame of ours.
156
+ socket.onclose = (event) => {
157
+ clearTimeout(timer)
158
+ closed = true
159
+ if (!accepted)
160
+ reject(
161
+ event.code === 4401
162
+ ? new Refused('the token was not accepted')
163
+ : new Error('the socket closed before it was accepted'),
164
+ )
165
+ wake?.()
84
166
  }
85
- wake?.()
86
- }
87
- socket.onclose = (event) => {
88
- closed = event
89
- wake?.()
90
- }
91
- for (;;) {
92
- const next = queue.shift()
93
- if (next !== undefined) {
94
- yield next
95
- continue
167
+ socket.onerror = () => {
168
+ clearTimeout(timer)
169
+ if (!accepted) reject(new Error('the server refused the upgrade'))
96
170
  }
97
- if (closed !== undefined) {
98
- if (closed.code === 4401)
99
- yield {
100
- event: 'error',
101
- data: { message: 'the panel was not accepted (reload the page and try again)' },
171
+ async function* read(): AsyncGenerator<PanelEvent> {
172
+ for (;;) {
173
+ const next = queue.shift()
174
+ if (next !== undefined) {
175
+ yield next
176
+ continue
102
177
  }
103
- return
178
+ if (closed) return
179
+ await new Promise<void>((done) => {
180
+ wake = done
181
+ })
182
+ }
104
183
  }
105
- await new Promise<void>((resolve) => {
106
- wake = resolve
107
- })
108
- }
184
+ })
109
185
  }
110
186
 
111
187
  const css = `
@@ -140,33 +216,86 @@ textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border
140
216
  .row button, header button { padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer }
141
217
  .row button.send { background: #1f2937; color: #fff; border-color: #1f2937 }
142
218
  .row button:disabled { opacity: 0.45; cursor: not-allowed }
219
+ .notice, .steps, .tab { display: none }
220
+ /* The Assistant (production): docked on the right in the app's dark surface, one tab, a note, the conversation and one line to ask. */
221
+ :host([data-assistant]) section { width: min(330px, 100vw); background: #18181b; color: #e4e4e7; border-left: 1px solid #27272a; box-shadow: none }
222
+ :host([data-assistant]) header { padding: 0 12px; border-bottom: 1px solid #27272a; min-height: 38px }
223
+ :host([data-assistant]) header select, :host([data-assistant]) header strong { display: none }
224
+ :host([data-assistant]) .tab { display: block; flex: 1; align-self: stretch }
225
+ :host([data-assistant]) .tab span { display: inline-block; height: 100%; padding: 10px 6px 8px; font-size: 12.5px; font-weight: 600; color: #fafafa; border-bottom: 2px solid #5468ff }
226
+ :host([data-assistant]) header button.close { border: 0; background: none; color: #a1a1aa; font-size: 18px; padding: 2px 6px }
227
+ :host([data-assistant]) .notice { display: block; margin: 8px 10px 0; padding: 7px 9px; border-radius: 6px; background: #15291e; color: #a7f3c0; font-size: 12px; line-height: 1.5 }
228
+ :host([data-assistant]) #log { padding: 10px; gap: 10px }
229
+ :host([data-assistant]) .msg { background: #232326; color: #e4e4e7; border-radius: 8px; padding: 9px 11px; line-height: 1.45; max-width: 92% }
230
+ :host([data-assistant]) .me { background: #23264f; color: #eef0ff; align-self: flex-end }
231
+ :host([data-assistant]) .err { background: #3b1d1d; color: #fecaca }
232
+ :host([data-assistant]) .tool, :host([data-assistant]) .working { background: none; color: #a1a1aa; padding: 0 2px }
233
+ :host([data-assistant]) .steps { display: block; margin-top: 6px; font: 11.5px ui-monospace, monospace; color: #a1a1aa; white-space: normal }
234
+ :host([data-assistant]) .steps:empty { display: none }
235
+ :host([data-assistant]) .md code { background: #2e2e33; color: #e4e4e7 }
236
+ :host([data-assistant]) form { border-top: 0; padding: 10px }
237
+ :host([data-assistant]) textarea { min-height: 0; height: 38px; resize: none; background: #18181b; color: #e4e4e7; border: 1px solid #3f3f46; border-radius: 8px; padding: 9px 11px; font-size: 13px }
238
+ :host([data-assistant]) textarea:focus { outline: none; border-color: #5468ff }
239
+ :host([data-assistant]) .row { display: none }
240
+ :host([data-assistant][data-following]) .row { display: flex; justify-content: flex-end }
241
+ :host([data-assistant]) .row .send { display: none }
242
+ :host([data-assistant]) .row button { background: none; color: #e4e4e7; border-color: #3f3f46 }
243
+ :host([data-assistant]) button.open { background: #18181b; color: #fafafa; border: 1px solid #3f3f46 }
143
244
  `
144
245
 
145
- async function start(): Promise<void> {
246
+ /** Start the panel against the app served at `prefix` (`/api/agent-panel` at the root, `/app/api/agent-panel` in the frame). */
247
+ export async function start(prefix: string = '/api/agent-panel'): Promise<void> {
248
+ PREFIX = prefix
249
+ // A panel that is mounted again replaces the previous one: one live host, and the readiness mark then belongs to the instance being wired now. A
250
+ // duplicated or reused host once let a submit reach a form that no live instance was listening to (the frame gate's empty-log failure, 2026-11-02).
251
+ // The previous instance's run socket goes with it, so its watch ends instead of writing into a detached log.
252
+ activeSocket?.close()
253
+ activeSocket = undefined
254
+ document.getElementById('agent-panel')?.remove()
146
255
  const reply = await fetch(`${PREFIX}/session`)
147
256
  if (!reply.ok) return
148
- const session = (await reply.json()) as Session
257
+ let session = (await reply.json()) as Session
149
258
 
259
+ // The frame's Build is the builder's screen, and its Preview shows the app as production would: the panel takes the Assistant's variant only where
260
+ // production has it, and it is hidden everywhere the canvas should be the app alone. A direct page (no frame) is unchanged.
261
+ const frame = frameViewOf()
262
+ const assistant = panelVariant(session, frame)
150
263
  const host = document.createElement('div')
151
264
  host.id = 'agent-panel'
265
+ if (assistant) host.dataset.assistant = ''
152
266
  const shadow = host.attachShadow({ mode: 'open' })
153
267
  shadow.innerHTML = `<style>${css}</style>
154
- <button class="open" type="button">Ask the agent</button>
155
- <section aria-label="Agent panel">
156
- <header><strong>Agent</strong><select aria-label="Agent">${session.drivers
268
+ <button class="open" type="button">${assistant ? 'Assistant' : 'Ask the agent'}</button>
269
+ <section aria-label="${assistant ? 'Assistant' : 'Agent panel'}">
270
+ <header><strong>Agent</strong><div class="tab"><span>Assistant</span></div><select aria-label="Agent">${session.drivers
157
271
  .map((d) => `<option>${d.name}</option>`)
158
272
  .join(
159
273
  '',
160
274
  )}</select><button class="termstop" type="button" hidden>Stop terminal</button><button class="close" type="button" aria-label="Close">×</button></header>
275
+ <p class="notice">${ASSISTANT_NOTE}</p>
161
276
  <div id="log" role="log"></div>
162
277
  <div id="term" hidden></div>
163
- <form><textarea aria-label="Message" placeholder="What should the app do?"></textarea>
278
+ <form><textarea aria-label="Message" placeholder="${assistant ? 'Ask the assistant…' : 'What should the app do?'}"${assistant ? ' rows="1"' : ''}></textarea>
164
279
  <div class="row"><button class="send" type="submit">Send</button><button class="stop" type="button" disabled>Stop</button></div></form>
165
280
  </section>`
166
281
  const $ = <T extends Element>(selector: string): T => shadow.querySelector<T>(selector)!
167
282
  const panel = $<HTMLElement>('section')
168
283
  const log = $<HTMLElement>('#log')
169
284
  const input = $<HTMLTextAreaElement>('textarea')
285
+ // The composer survives the page being replaced (a run that changes files reloads the page, and a mount replaces this panel): the draft is kept
286
+ // beside the page, restored into this textarea before the form is wired, and cleared when a submit sends it.
287
+ try {
288
+ input.value = sessionStorage.getItem(DRAFT_KEY) ?? ''
289
+ } catch {
290
+ // No storage: the draft lives as long as this document.
291
+ }
292
+ input.addEventListener('input', () => {
293
+ try {
294
+ sessionStorage.setItem(DRAFT_KEY, input.value)
295
+ } catch {
296
+ // No storage: the draft lives as long as this document.
297
+ }
298
+ })
170
299
  const send = $<HTMLButtonElement>('.send')
171
300
  const stop = $<HTMLButtonElement>('.stop')
172
301
  const driver = $<HTMLSelectElement>('select')
@@ -175,34 +304,52 @@ async function start(): Promise<void> {
175
304
  termStop.addEventListener('click', () => void terminal?.stop())
176
305
  const form = $<HTMLFormElement>('form')
177
306
  document.body.append(host)
307
+ const applyFrame = (): void => {
308
+ host.style.display = panelVisible(frame, assistant) ? '' : 'none'
309
+ }
310
+ window.addEventListener(FRAME_MODE_EVENT, applyFrame)
311
+ applyFrame()
178
312
  const kindOf = (name: string): 'chat' | 'terminal' =>
179
313
  session.drivers.find((d) => d.name === name)?.kind ?? 'chat'
180
314
  let terminal: { detach(): void; stop(): Promise<void> } | undefined
315
+ // A pick can land while an earlier pick's terminal is still loading (the page also restores a remembered terminal at
316
+ // start). Only the latest pick may apply its result; a stale terminal must never replace the form the person chose.
317
+ const picks = createPickGeneration()
181
318
  // A terminal driver swaps the chat for the driver's own terminal (loaded from the plugin that provides it, only when asked for).
182
319
  const choose = async (): Promise<void> => {
320
+ const generation = picks.begin()
321
+ const name = driver.value
183
322
  terminal?.detach()
184
323
  terminal = undefined
185
- const isTerminal = kindOf(driver.value) === 'terminal'
324
+ const isTerminal = kindOf(name) === 'terminal'
186
325
  log.hidden = isTerminal
187
326
  form.hidden = isTerminal
188
327
  term.hidden = !isTerminal
189
328
  termStop.hidden = !isTerminal
190
329
  if (!isTerminal) return
330
+ const selected = session.drivers.find((entry) => entry.name === name)
331
+ if (selected?.view === undefined) {
332
+ line('This terminal driver does not provide its view.', 'err')
333
+ return
334
+ }
191
335
  try {
192
- const url = '/api/driver-pty/client/terminal.ts'
336
+ const url = selected.view.module
193
337
  const { openTerminal } = (await import(url)) as {
194
338
  openTerminal(options: {
195
339
  container: HTMLElement
196
340
  token: string
197
341
  driver: string
342
+ prefix: string
198
343
  status(text: string): void
199
344
  onChange(change: { extension: string; action: string }): void
200
345
  }): Promise<{ detach(): void; stop(): Promise<void> }>
201
346
  }
202
- terminal = await openTerminal({
347
+ await refreshSession()
348
+ const opened = await openTerminal({
203
349
  container: term,
204
350
  token: session.token,
205
- driver: driver.value,
351
+ driver: name,
352
+ prefix: selected.view.prefix,
206
353
  status: (text) => line(text, 'tool'),
207
354
  onChange: (change) => {
208
355
  line(`plugin ${change.extension}: ${change.action}`, 'tool')
@@ -210,7 +357,13 @@ async function start(): Promise<void> {
210
357
  setTimeout(() => location.reload(), 900)
211
358
  },
212
359
  })
360
+ if (!picks.isCurrent(generation)) {
361
+ opened.detach()
362
+ return
363
+ }
364
+ terminal = opened
213
365
  } catch (error) {
366
+ if (!picks.isCurrent(generation)) return
214
367
  line(
215
368
  `The terminal could not open: ${error instanceof Error ? error.message : String(error)}`,
216
369
  'err',
@@ -231,9 +384,10 @@ async function start(): Promise<void> {
231
384
  root.style.getPropertyValue(property),
232
385
  root.style.getPropertyPriority(property),
233
386
  ])
387
+ // The panel's own width (the Assistant's is narrower than the builder's), so the page meets its edge with no gap.
234
388
  root.style.setProperty(
235
389
  'margin-right',
236
- `${String(Math.min(380, Math.floor(window.innerWidth * 0.9)))}px`,
390
+ `${String(Math.ceil(panel.getBoundingClientRect().width))}px`,
237
391
  'important',
238
392
  )
239
393
  } else {
@@ -245,14 +399,39 @@ async function start(): Promise<void> {
245
399
  saved.clear()
246
400
  }
247
401
  }
248
- const show = (open: boolean): void => {
402
+ const show = (open: boolean, focus = true): void => {
249
403
  const was = panel.classList.contains('shown')
250
404
  panel.classList.toggle('shown', open)
405
+ $<HTMLButtonElement>('button.open').hidden = open && assistant
251
406
  if (open !== was) dock(open)
252
- if (open) input.focus()
407
+ if (open && focus) input.focus()
408
+ // Other corner buttons of the page (the feedback button) stand clear of the panel.
409
+ window.dispatchEvent(new Event('agent-panel:layout'))
410
+ }
411
+ const remember = (closed: boolean): void => {
412
+ try {
413
+ if (closed) sessionStorage.setItem(ASSISTANT_CLOSED_KEY, '1')
414
+ else sessionStorage.removeItem(ASSISTANT_CLOSED_KEY)
415
+ } catch {
416
+ // No storage: the Assistant opens again on the next page.
417
+ }
253
418
  }
254
- $('button.open').addEventListener('click', () => show(true))
255
- $('button.close').addEventListener('click', () => show(false))
419
+ $('button.open').addEventListener('click', () => {
420
+ show(true)
421
+ if (assistant) remember(false)
422
+ })
423
+ $('button.close').addEventListener('click', () => {
424
+ show(false)
425
+ if (assistant) remember(true)
426
+ })
427
+ // Enter asks (Shift+Enter is a new line): the Assistant has one line to ask in and no Send button.
428
+ if (assistant)
429
+ input.addEventListener('keydown', (event) => {
430
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
431
+ event.preventDefault()
432
+ form.requestSubmit()
433
+ }
434
+ })
256
435
  const line = (text: string, kind = ''): HTMLElement => {
257
436
  const el = document.createElement('div')
258
437
  el.className = `msg ${kind}`
@@ -265,26 +444,41 @@ async function start(): Promise<void> {
265
444
  // A run belongs to the app, not to this page: it goes on when the page reloads (the door reloads it after a plugin change, an agent may move it to another page) and the page that
266
445
  // loads next follows it again. Only Stop cancels it.
267
446
  const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
447
+ /**
448
+ * The token belongs to one start of the app: `node --watch` restarts it at every change of a file, and a page left open across that restart holds a token the new server
449
+ * does not know. Ask for the session again; the caller tries once more with what it gets.
450
+ */
451
+ const refreshSession = async (): Promise<void> => {
452
+ const reply = await fetch(`${PREFIX}/session`)
453
+ if (!reply.ok) return
454
+ session = (await reply.json()) as Session
455
+ headers['x-agent-panel-token'] = session.token
456
+ }
268
457
  let following = false
269
- stop.addEventListener('click', () => {
458
+ const stopRun = (): void => {
270
459
  // On the run's socket when there is one: a request would wait behind the browser's six connections per host.
271
460
  if (activeSocket?.readyState === WebSocket.OPEN)
272
461
  activeSocket.send(JSON.stringify({ t: 'stop' }))
273
462
  else void fetch(`${PREFIX}/stop`, { method: 'POST', headers })
274
- })
275
- /** A run over the socket when the server upgrades, else over the stream, with one console warning saying why. */
276
- const openRun = async (
277
- action: { t: 'ask'; message: string; driver: string } | { t: 'follow' },
278
- ): Promise<RunSource | { error: string } | undefined> => {
463
+ }
464
+ stop.addEventListener('click', stopRun)
465
+ // The door's "Agent is driving" bar has a Stop too (the same page): it ends this run.
466
+ window.addEventListener('acryl:stop-run', stopRun)
467
+ const REFUSED = Symbol('refused')
468
+ /** A run over the socket when the server upgrades, else over the stream, with one console warning saying why. `REFUSED` when the server does not know the token. */
469
+ const tryOpenRun = async (
470
+ action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
471
+ ): Promise<RunSource | { error: string } | undefined | typeof REFUSED> => {
279
472
  if (!socketBroken && typeof WebSocket !== 'undefined') {
280
473
  try {
281
- const socket = await openSocket(session.token, action)
474
+ const { socket, events: frames } = await openSocket(session.token, action)
282
475
  activeSocket = socket
283
476
  socket.addEventListener('close', () => {
284
477
  if (activeSocket === socket) activeSocket = undefined
285
478
  })
286
- return { events: socketEvents(socket) }
479
+ return { events: frames }
287
480
  } catch (cause) {
481
+ if (cause instanceof Refused) return REFUSED
288
482
  socketBroken = true
289
483
  console.warn(
290
484
  `agent-panel: the run could not open a WebSocket (${cause instanceof Error ? cause.message : String(cause)}); using server-sent events, which count against the browser's six connections per host`,
@@ -295,8 +489,9 @@ async function start(): Promise<void> {
295
489
  const response = await fetch(`${PREFIX}/ask`, {
296
490
  method: 'POST',
297
491
  headers,
298
- body: JSON.stringify({ message: action.message, driver: action.driver }),
492
+ body: JSON.stringify({ message: action.message, driver: action.driver, page: action.page }),
299
493
  })
494
+ if (response.status === 403) return REFUSED
300
495
  if (!response.ok)
301
496
  return {
302
497
  error:
@@ -306,10 +501,23 @@ async function start(): Promise<void> {
306
501
  return { events: events(response) }
307
502
  }
308
503
  const latest = await fetch(`${PREFIX}/run`, { headers })
504
+ if (latest.status === 403) return REFUSED
309
505
  return latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream')
310
506
  ? { events: events(latest) }
311
507
  : undefined
312
508
  }
509
+ /** `tryOpenRun`, and when the token was refused, a new session and one more try without the person seeing anything; a second refusal is shown. */
510
+ const openRun = async (
511
+ action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
512
+ ): Promise<RunSource | { error: string } | undefined> => {
513
+ const first = await tryOpenRun(action)
514
+ if (first !== REFUSED) return first
515
+ await refreshSession()
516
+ const second = await tryOpenRun(action)
517
+ return second === REFUSED
518
+ ? { error: 'the panel was not accepted (reload the page and try again)' }
519
+ : second
520
+ }
313
521
  /**
314
522
  * Show one run, as its events come, until it ends. `own` is true for a run this page just started (its question is on screen); a run found on load is shown from its question, and
315
523
  * a run that had already ended before the page loaded is only shown, never reloads the page.
@@ -319,6 +527,10 @@ async function start(): Promise<void> {
319
527
  let changed = false
320
528
  let answer: HTMLElement | undefined
321
529
  let answered = ''
530
+ // The Assistant's steps on the page, shown under its answer in one line, as the person reads what it did.
531
+ const steps: string[] = []
532
+ const stepsLine = document.createElement('div')
533
+ stepsLine.className = 'steps'
322
534
  // A coding agent started headless says little until it is done, so the panel says it is working, and for how long (from when the run began, not from when this page loaded).
323
535
  let started = Date.now()
324
536
  let working: HTMLElement | undefined
@@ -334,6 +546,7 @@ async function start(): Promise<void> {
334
546
  const begin = (): void => {
335
547
  if (working !== undefined) return
336
548
  following = true
549
+ host.dataset.following = '1'
337
550
  send.disabled = true
338
551
  stop.disabled = false
339
552
  working = line('', 'working')
@@ -362,8 +575,17 @@ async function start(): Promise<void> {
362
575
  answer ??= line('', 'md')
363
576
  answered += String(data.text ?? '')
364
577
  answer.replaceChildren(renderMarkdown(answered, document))
578
+ if (assistant) {
579
+ stepsLine.textContent = steps.join(' · ')
580
+ answer.append(stepsLine)
581
+ }
365
582
  log.scrollTop = log.scrollHeight
366
583
  } else if (event === 'tool') {
584
+ if (assistant) {
585
+ const step = describePageStep(data)
586
+ if (step !== undefined) steps.push(step)
587
+ continue
588
+ }
367
589
  const described = describeTool(data)
368
590
  if (described !== undefined) line(described, 'tool')
369
591
  } else if (event === 'change') {
@@ -382,25 +604,52 @@ async function start(): Promise<void> {
382
604
  if (working !== undefined) {
383
605
  working.remove()
384
606
  following = false
607
+ delete host.dataset.following
385
608
  send.disabled = false
386
609
  stop.disabled = true
387
610
  }
388
611
  }
389
- // A plugin was installed or removed while this page watched: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
390
- // A run that ended before this page loaded was already shown after its reload, and does not reload it again.
391
- if (live && changed && input.value.trim() === '') {
612
+ // A plugin was installed or removed while this page watched: the new feature is served by the running app, so the page only needs to be loaded again.
613
+ // The panel lives inside the app's own page (it cannot refresh the frame's canvas from here), so the page reload is the app's refresh - but the decision
614
+ // to reload is made at the moment of reloading, never on a check made a second earlier: a person (or the frame gate) can start typing in between, and a
615
+ // reload then replaces the page under their text (the frame gate's empty-composer dump, 2026-11-02).
616
+ if (live && changed) {
392
617
  line('Reloading the page to show it.', 'tool')
393
- setTimeout(() => location.reload(), 900)
618
+ setTimeout(() => {
619
+ if (
620
+ reloadDecision({
621
+ value: input.value,
622
+ focused: host.shadowRoot?.activeElement === input,
623
+ following,
624
+ })
625
+ )
626
+ reloadPage('a plugin changed')
627
+ }, 900)
394
628
  }
395
629
  }
396
630
  form.addEventListener('submit', async (submitted) => {
397
631
  submitted.preventDefault()
632
+ const refusal = sendRefusal(input.value, following)
633
+ if (refusal !== undefined) {
634
+ line(
635
+ refusal === 'empty' ? 'Type a message first.' : 'A run is already going; stop it first.',
636
+ 'err',
637
+ )
638
+ return
639
+ }
398
640
  const message = input.value.trim()
399
- if (message === '' || following) return
400
641
  input.value = ''
642
+ try {
643
+ sessionStorage.removeItem(DRAFT_KEY)
644
+ } catch {
645
+ // No storage.
646
+ }
647
+ // The message is sent: the composer is empty and the person is not typing, so it gives the focus back. A run that installs a plugin reloads the page
648
+ // afterwards, and that reload must not be refused for a focus nobody is using.
649
+ input.blur()
401
650
  line(message, 'me')
402
651
  try {
403
- const run = await openRun({ t: 'ask', message, driver: driver.value })
652
+ const run = await openRun({ t: 'ask', message, driver: driver.value, ...doorPage() })
404
653
  if (run === undefined) return
405
654
  if ('error' in run) {
406
655
  line(run.error, 'err')
@@ -411,7 +660,22 @@ async function start(): Promise<void> {
411
660
  line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
412
661
  }
413
662
  })
414
- // The latest run, if there is one: shown again after a reload (its question, its events), and followed if it is still going.
663
+ // The Assistant is open beside the page unless the visitor closed it.
664
+ if (assistant) {
665
+ let closed = false
666
+ try {
667
+ closed = sessionStorage.getItem(ASSISTANT_CLOSED_KEY) === '1'
668
+ } catch {
669
+ // No storage: open.
670
+ }
671
+ show(!closed, false)
672
+ }
673
+ // The form is wired: a script (the frame gate included) waits for this instead of guessing that the panel can be submitted, and a panel that never got
674
+ // this far says so by the mark's absence - together with the visible guards above, no submit can vanish without a trace.
675
+ host.dataset.ready = '1'
676
+ // The latest run, if there is one: shown again after a reload (its question, its events), and followed if it is still going. The server records a run
677
+ // before it answers the ask that started it (startRun sets the current run, then follow answers), so a page that asks after that answer always finds
678
+ // it - no client poll is needed. A run the app server itself restarted away (a file change the run made) no longer exists for that server to show.
415
679
  try {
416
680
  const latest = await openRun({ t: 'follow' })
417
681
  if (latest !== undefined && !('error' in latest)) void watch(latest, false)
@@ -432,5 +696,3 @@ async function start(): Promise<void> {
432
696
  // No storage: nothing to show again.
433
697
  }
434
698
  }
435
-
436
- void start()
@@ -16,8 +16,8 @@ export type ChatEvent =
16
16
  export interface ChatDriver {
17
17
  readonly kind: 'chat'
18
18
  readonly name: string
19
- /** Runs one message. Aborting `signal` stops the run. */
20
- start(options: { message: string; signal: AbortSignal }): AsyncIterable<ChatEvent>
19
+ /** Runs one message. Aborting `signal` stops the run. `page` is the door's id of the page whose panel asked, when it sent one: a driver that uses the page drives that one. */
20
+ start(options: { message: string; signal: AbortSignal; page?: string }): AsyncIterable<ChatEvent>
21
21
  }
22
22
 
23
23
  /** A live pseudo-terminal session. */
@@ -34,6 +34,11 @@ export interface TerminalSession {
34
34
  export interface TerminalDriver {
35
35
  readonly kind: 'terminal'
36
36
  readonly name: string
37
+ /**
38
+ * Where the panel loads the terminal view from: the browser module that draws it, and the URL prefix the view's endpoints live under. The driver
39
+ * plugin serves both, so the view follows wherever the app is mounted (the root of its own server, or the prefix the ACRYL frame serves it at).
40
+ */
41
+ readonly view: { readonly module: string; readonly prefix: string }
37
42
  start(options: {
38
43
  cwd: string
39
44
  env: NodeJS.ProcessEnv