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
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The ACRYL frame's view as the app page sees it (ide/acryl-blends-frame/src/client/frame-view.ts): the frame's canvas hides the app's own build-time
3
+ * controls in Build and shows what production shows in Preview. The page is same-origin with the frame, so it reads the handle the frame publishes on
4
+ * its window through `window.parent`; a page opened directly has no such parent and keeps its own behavior.
5
+ */
6
+
7
+ export const FRAME_VIEW_KEY: symbol = Symbol.for('acryl.frame.view')
8
+ export const FRAME_MODE_EVENT = 'acryl:frame-mode'
9
+
10
+ /** What the app page reads from `window.parent[FRAME_VIEW_KEY]`. */
11
+ export interface FrameViewHandle {
12
+ readonly mode: 'build' | 'preview'
13
+ }
14
+
15
+ /**
16
+ * The frame's view handle when this page runs in the frame's canvas.
17
+ * @param win - the page's window.
18
+ * @returns the handle, or undefined when the page is the top window or its parent is cross-origin.
19
+ */
20
+ export function frameViewOf(win: Window = window): FrameViewHandle | undefined {
21
+ try {
22
+ if (win.parent === win) return undefined
23
+ return (win.parent as unknown as Record<symbol, FrameViewHandle | undefined>)[FRAME_VIEW_KEY]
24
+ } catch {
25
+ return undefined
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Whether the panel is the production Assistant: the Host itself is the Assistant, or the app's production environment names it (ACRYL_ASSISTANT=on)
31
+ * and the page runs in the frame, whose Preview shows the app as production would.
32
+ * @param session - the panel's session from its Host.
33
+ * @param frame - the frame's view handle, if any.
34
+ */
35
+ export function panelVariant(
36
+ session: { readonly assistant?: boolean; readonly assistantInProduction?: boolean },
37
+ frame: FrameViewHandle | undefined,
38
+ ): boolean {
39
+ if (session.assistant === true) return true
40
+ return frame !== undefined && session.assistantInProduction === true
41
+ }
42
+
43
+ /**
44
+ * Whether the panel shows at all: on a page opened directly, yes; in the frame, only in Preview, and only as the Assistant. Build is the builder's
45
+ * screen, where the app's own panel has no place over the canvas.
46
+ * @param frame - the frame's view handle, if any.
47
+ * @param assistant - whether this panel is the production Assistant (see {@link panelVariant}).
48
+ */
49
+ export function panelVisible(frame: FrameViewHandle | undefined, assistant: boolean): boolean {
50
+ return frame === undefined || (frame.mode === 'preview' && assistant)
51
+ }
@@ -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
@@ -160,33 +216,86 @@ textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border
160
216
  .row button, header button { padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer }
161
217
  .row button.send { background: #1f2937; color: #fff; border-color: #1f2937 }
162
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 }
163
244
  `
164
245
 
165
- 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()
166
255
  const reply = await fetch(`${PREFIX}/session`)
167
256
  if (!reply.ok) return
168
257
  let session = (await reply.json()) as Session
169
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)
170
263
  const host = document.createElement('div')
171
264
  host.id = 'agent-panel'
265
+ if (assistant) host.dataset.assistant = ''
172
266
  const shadow = host.attachShadow({ mode: 'open' })
173
267
  shadow.innerHTML = `<style>${css}</style>
174
- <button class="open" type="button">Ask the agent</button>
175
- <section aria-label="Agent panel">
176
- <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
177
271
  .map((d) => `<option>${d.name}</option>`)
178
272
  .join(
179
273
  '',
180
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>
181
276
  <div id="log" role="log"></div>
182
277
  <div id="term" hidden></div>
183
- <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>
184
279
  <div class="row"><button class="send" type="submit">Send</button><button class="stop" type="button" disabled>Stop</button></div></form>
185
280
  </section>`
186
281
  const $ = <T extends Element>(selector: string): T => shadow.querySelector<T>(selector)!
187
282
  const panel = $<HTMLElement>('section')
188
283
  const log = $<HTMLElement>('#log')
189
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
+ })
190
299
  const send = $<HTMLButtonElement>('.send')
191
300
  const stop = $<HTMLButtonElement>('.stop')
192
301
  const driver = $<HTMLSelectElement>('select')
@@ -195,35 +304,52 @@ async function start(): Promise<void> {
195
304
  termStop.addEventListener('click', () => void terminal?.stop())
196
305
  const form = $<HTMLFormElement>('form')
197
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()
198
312
  const kindOf = (name: string): 'chat' | 'terminal' =>
199
313
  session.drivers.find((d) => d.name === name)?.kind ?? 'chat'
200
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()
201
318
  // A terminal driver swaps the chat for the driver's own terminal (loaded from the plugin that provides it, only when asked for).
202
319
  const choose = async (): Promise<void> => {
320
+ const generation = picks.begin()
321
+ const name = driver.value
203
322
  terminal?.detach()
204
323
  terminal = undefined
205
- const isTerminal = kindOf(driver.value) === 'terminal'
324
+ const isTerminal = kindOf(name) === 'terminal'
206
325
  log.hidden = isTerminal
207
326
  form.hidden = isTerminal
208
327
  term.hidden = !isTerminal
209
328
  termStop.hidden = !isTerminal
210
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
+ }
211
335
  try {
212
- const url = '/api/driver-pty/client/terminal.ts'
336
+ const url = selected.view.module
213
337
  const { openTerminal } = (await import(url)) as {
214
338
  openTerminal(options: {
215
339
  container: HTMLElement
216
340
  token: string
217
341
  driver: string
342
+ prefix: string
218
343
  status(text: string): void
219
344
  onChange(change: { extension: string; action: string }): void
220
345
  }): Promise<{ detach(): void; stop(): Promise<void> }>
221
346
  }
222
347
  await refreshSession()
223
- terminal = await openTerminal({
348
+ const opened = await openTerminal({
224
349
  container: term,
225
350
  token: session.token,
226
- driver: driver.value,
351
+ driver: name,
352
+ prefix: selected.view.prefix,
227
353
  status: (text) => line(text, 'tool'),
228
354
  onChange: (change) => {
229
355
  line(`plugin ${change.extension}: ${change.action}`, 'tool')
@@ -231,7 +357,13 @@ async function start(): Promise<void> {
231
357
  setTimeout(() => location.reload(), 900)
232
358
  },
233
359
  })
360
+ if (!picks.isCurrent(generation)) {
361
+ opened.detach()
362
+ return
363
+ }
364
+ terminal = opened
234
365
  } catch (error) {
366
+ if (!picks.isCurrent(generation)) return
235
367
  line(
236
368
  `The terminal could not open: ${error instanceof Error ? error.message : String(error)}`,
237
369
  'err',
@@ -252,9 +384,10 @@ async function start(): Promise<void> {
252
384
  root.style.getPropertyValue(property),
253
385
  root.style.getPropertyPriority(property),
254
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.
255
388
  root.style.setProperty(
256
389
  'margin-right',
257
- `${String(Math.min(380, Math.floor(window.innerWidth * 0.9)))}px`,
390
+ `${String(Math.ceil(panel.getBoundingClientRect().width))}px`,
258
391
  'important',
259
392
  )
260
393
  } else {
@@ -266,14 +399,39 @@ async function start(): Promise<void> {
266
399
  saved.clear()
267
400
  }
268
401
  }
269
- const show = (open: boolean): void => {
402
+ const show = (open: boolean, focus = true): void => {
270
403
  const was = panel.classList.contains('shown')
271
404
  panel.classList.toggle('shown', open)
405
+ $<HTMLButtonElement>('button.open').hidden = open && assistant
272
406
  if (open !== was) dock(open)
273
- 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
+ }
274
418
  }
275
- $('button.open').addEventListener('click', () => show(true))
276
- $('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
+ })
277
435
  const line = (text: string, kind = ''): HTMLElement => {
278
436
  const el = document.createElement('div')
279
437
  el.className = `msg ${kind}`
@@ -369,6 +527,10 @@ async function start(): Promise<void> {
369
527
  let changed = false
370
528
  let answer: HTMLElement | undefined
371
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'
372
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).
373
535
  let started = Date.now()
374
536
  let working: HTMLElement | undefined
@@ -384,6 +546,7 @@ async function start(): Promise<void> {
384
546
  const begin = (): void => {
385
547
  if (working !== undefined) return
386
548
  following = true
549
+ host.dataset.following = '1'
387
550
  send.disabled = true
388
551
  stop.disabled = false
389
552
  working = line('', 'working')
@@ -412,8 +575,17 @@ async function start(): Promise<void> {
412
575
  answer ??= line('', 'md')
413
576
  answered += String(data.text ?? '')
414
577
  answer.replaceChildren(renderMarkdown(answered, document))
578
+ if (assistant) {
579
+ stepsLine.textContent = steps.join(' · ')
580
+ answer.append(stepsLine)
581
+ }
415
582
  log.scrollTop = log.scrollHeight
416
583
  } else if (event === 'tool') {
584
+ if (assistant) {
585
+ const step = describePageStep(data)
586
+ if (step !== undefined) steps.push(step)
587
+ continue
588
+ }
417
589
  const described = describeTool(data)
418
590
  if (described !== undefined) line(described, 'tool')
419
591
  } else if (event === 'change') {
@@ -432,22 +604,49 @@ async function start(): Promise<void> {
432
604
  if (working !== undefined) {
433
605
  working.remove()
434
606
  following = false
607
+ delete host.dataset.following
435
608
  send.disabled = false
436
609
  stop.disabled = true
437
610
  }
438
611
  }
439
- // 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).
440
- // A run that ended before this page loaded was already shown after its reload, and does not reload it again.
441
- 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) {
442
617
  line('Reloading the page to show it.', 'tool')
443
- 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)
444
628
  }
445
629
  }
446
630
  form.addEventListener('submit', async (submitted) => {
447
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
+ }
448
640
  const message = input.value.trim()
449
- if (message === '' || following) return
450
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()
451
650
  line(message, 'me')
452
651
  try {
453
652
  const run = await openRun({ t: 'ask', message, driver: driver.value, ...doorPage() })
@@ -461,7 +660,22 @@ async function start(): Promise<void> {
461
660
  line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
462
661
  }
463
662
  })
464
- // 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.
465
679
  try {
466
680
  const latest = await openRun({ t: 'follow' })
467
681
  if (latest !== undefined && !('error' in latest)) void watch(latest, false)
@@ -482,5 +696,3 @@ async function start(): Promise<void> {
482
696
  // No storage: nothing to show again.
483
697
  }
484
698
  }
485
-
486
- 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
@@ -112,8 +112,11 @@ export function agentDriver(ctx: Context): ChatDriver {
112
112
  return {
113
113
  kind: 'chat',
114
114
  name: 'agent',
115
- async *start({ message, signal }) {
116
- const result = await ctx.agent.run(message, { signal })
115
+ async *start({ message, signal, page }) {
116
+ const result = await ctx.agent.run(message, {
117
+ signal,
118
+ ...(page === undefined ? {} : { page }),
119
+ })
117
120
  for (const call of result.trace)
118
121
  yield {
119
122
  event: 'tool',