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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.0.10",
3
+ "version": "1.2.0",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -405,6 +405,7 @@
405
405
  "dist",
406
406
  "lib",
407
407
  "templates",
408
+ "legacy",
408
409
  "shared",
409
410
  "plugins",
410
411
  "docs"
@@ -17,6 +17,8 @@ import {
17
17
  /** The slice of a page's stream the channel needs, so tests can supply their own. */
18
18
  export interface PageStream {
19
19
  send(data: string): void
20
+ /** Tell the page something that is not a call (`run`: `1` while a run asked for from its panel goes, `0` when it ends). A stream that cannot (a test's) leaves it out. */
21
+ notify?(event: string, data: string): void
20
22
  close(): void
21
23
  }
22
24
 
@@ -42,6 +44,8 @@ interface Connection {
42
44
  readonly windowId: string
43
45
  /** The desktop window the page is in, when the Electron shell tagged it. */
44
46
  readonly shellWindow: number | undefined
47
+ /** The browser tab the page is in (the page keeps it for the tab's lifetime): a page that reloads or moves to another path comes back with a new window id and the same tab. */
48
+ readonly tab: string | undefined
45
49
  focused: boolean
46
50
  /** Order of last focus (or connection), so the most recent wins. */
47
51
  lastActive: number
@@ -62,21 +66,39 @@ export interface ChannelOptions {
62
66
  * address registers again a moment later). 0 (the default) fails at once.
63
67
  */
64
68
  readonly attachWaitMs?: number
69
+ /**
70
+ * Every call must name the page it is for (`page`), and goes to that page alone: never to the page in front or the one used last, which may be another visitor's. A page-only door
71
+ * (a production assistant, one channel for every visitor of the app) sets it.
72
+ */
73
+ readonly requirePage?: boolean
74
+ }
75
+
76
+ /** A page whose panel asked for a run: the id the run knows it by, and where that page is now (the same tab after a reload or a move). */
77
+ interface Asker {
78
+ /** The window id the run was asked from: what its calls name as `page`. */
79
+ readonly origin: string
80
+ windowId: string
81
+ tab: string | undefined
65
82
  }
66
83
 
67
84
  /**
68
- * The pages and who drives which. One page answers a call, chosen the same way every time: the page the Host just sent somewhere (it comes back as a new page, `prefer`), else the
69
- * one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
85
+ * The pages and who drives which. One page answers a call, chosen the same way every time: the page whose panel asked for the run that is making the call (`driveFor`), else the page the
86
+ * Host just sent somewhere (it comes back as a new page, `prefer`), else the one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
87
+ * A call that names its `page` (the page a run was asked from) goes to that page alone, followed into its tab when it reloads or moves, and to no other.
70
88
  */
71
89
  export class UiChannel {
72
90
  private readonly ackMs: number
73
91
  private readonly attachWaitMs: number
92
+ private readonly requirePage: boolean
74
93
  /** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
75
94
  private preferred: string | undefined
95
+ /** The pages whose panels have a run going, the latest last: a call with no `window` goes to the latest of them that is still connected. */
96
+ private readonly askers: Asker[] = []
76
97
 
77
98
  constructor(options: ChannelOptions = {}) {
78
99
  this.ackMs = options.ackMs ?? DEFAULT_ACK_MS
79
100
  this.attachWaitMs = options.attachWaitMs ?? 0
101
+ this.requirePage = options.requirePage === true
80
102
  }
81
103
 
82
104
  private readonly connections = new Map<string, Connection>()
@@ -91,12 +113,19 @@ export class UiChannel {
91
113
  * Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
92
114
  * @returns the function to call when this stream ends.
93
115
  */
94
- attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number): () => void {
116
+ attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number, tab?: string): () => void {
95
117
  const previous = this.connections.get(windowId)
96
118
  if (previous !== undefined) this.drop(previous, true)
97
119
  // A page that did not answer stays last in line when its stream reconnects: a tab that cannot answer reconnects just as readily, and would otherwise be first again.
98
- const connection: Connection = { stream, windowId, shellWindow, focused, lastActive: ++this.clock, suspect: previous?.suspect === true }
120
+ const connection: Connection = { stream, windowId, shellWindow, tab, focused, lastActive: ++this.clock, suspect: previous?.suspect === true }
99
121
  this.connections.set(windowId, connection)
122
+ // A run's page that reloaded or moved comes back as a new page in the same tab: the run goes on with it.
123
+ for (const asker of this.askers) {
124
+ if (asker.windowId === windowId) asker.tab ??= tab
125
+ else if (tab !== undefined && asker.tab === tab) asker.windowId = windowId
126
+ }
127
+ // A page that reloaded or came back during its own run is told the run is still going.
128
+ if (this.askers.some(asker => asker.windowId === windowId)) this.tell(connection, '1')
100
129
  for (const waiter of [...this.attachWaiters]) waiter(windowId)
101
130
  return () => { if (this.connections.get(windowId) === connection) this.drop(connection, false) }
102
131
  }
@@ -137,12 +166,71 @@ export class UiChannel {
137
166
  return this.ordered().map(({ windowId, focused, shellWindow }) => ({ windowId, focused, ...(shellWindow === undefined ? {} : { shellWindow }) }))
138
167
  }
139
168
 
169
+ /**
170
+ * A run asked for from this page's panel is going: its calls (which name no window) are for this page, whatever else is open and whichever was driven last, until the run ends.
171
+ * An explicit desktop `window` still picks its own page.
172
+ * @returns the function to call when the run ends.
173
+ */
174
+ driveFor(windowId: string): () => void {
175
+ const asker: Asker = { origin: windowId, windowId, tab: this.connections.get(windowId)?.tab }
176
+ this.askers.push(asker)
177
+ this.tellPage(windowId, '1')
178
+ return () => {
179
+ const at = this.askers.indexOf(asker)
180
+ if (at === -1) return
181
+ this.askers.splice(at, 1)
182
+ // The page keeps the bar if another run of its own is still going.
183
+ if (!this.askers.some(other => other.windowId === asker.windowId)) this.tellPage(asker.windowId, '0')
184
+ }
185
+ }
186
+
187
+ /** Where the page a run was asked from is now: the same window, or the page that replaced it in the same tab. */
188
+ private whereIs(page: string): { windowId: string; tab: string | undefined } {
189
+ const asker = [...this.askers].reverse().find(entry => entry.origin === page)
190
+ return asker === undefined ? { windowId: page, tab: this.connections.get(page)?.tab } : { windowId: asker.windowId, tab: asker.tab }
191
+ }
192
+
193
+ /**
194
+ * A promise for the run's page coming back after a reload or a move: the next page that connects in its tab (or with its window id). Ask for it BEFORE the call that makes the page
195
+ * leave. Pages of other tabs, another visitor's among them, are never taken for it.
196
+ * @param signal - stop waiting (the act did not move the page after all); the promise then answers `undefined`.
197
+ * @returns the window id of the page that came back, or `undefined` when it did not within `timeoutMs`.
198
+ */
199
+ expectPageBack(page: string, timeoutMs: number, signal?: AbortSignal): Promise<string | undefined> {
200
+ const { windowId, tab } = this.whereIs(page)
201
+ return new Promise((resolve) => {
202
+ const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); signal?.removeEventListener('abort', stop); resolve(value) }
203
+ const stop = (): void => { done(undefined) }
204
+ signal?.addEventListener('abort', stop, { once: true })
205
+ const waiter = (attached: string): void => {
206
+ const connection = this.connections.get(attached)
207
+ if (attached === windowId || (tab !== undefined && connection?.tab === tab)) done(attached)
208
+ }
209
+ const timer = setTimeout(() => { done(undefined) }, timeoutMs)
210
+ this.attachWaiters.add(waiter)
211
+ })
212
+ }
213
+
214
+ private tellPage(windowId: string, running: '0' | '1'): void {
215
+ const connection = this.connections.get(windowId)
216
+ if (connection !== undefined) this.tell(connection, running)
217
+ }
218
+
219
+ private tell(connection: Connection, running: '0' | '1'): void {
220
+ try { connection.stream.notify?.('run', running) } catch { /* a stream that is going away says so by closing */ }
221
+ }
222
+
140
223
  /** Drive this page from now on (it is the one the Host just sent to a new place), until the person focuses another. */
141
224
  prefer(windowId: string): void {
142
225
  const connection = this.connections.get(windowId)
143
226
  if (connection === undefined) return
144
227
  this.preferred = windowId
145
228
  connection.lastActive = ++this.clock
229
+ // A run's own page that reloaded or navigated comes back as a new page: the run goes on with that one. A run whose page has a tab follows only that tab, so
230
+ // another visitor's page is never taken for it.
231
+ for (const asker of this.askers)
232
+ if (!this.connections.has(asker.windowId) && (asker.tab === undefined || asker.tab === connection.tab)) asker.windowId = windowId
233
+ if (this.askers.some(asker => asker.windowId === windowId)) this.tellPage(windowId, '1')
146
234
  }
147
235
 
148
236
  /**
@@ -150,9 +238,11 @@ export class UiChannel {
150
238
  * until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
151
239
  * @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
152
240
  */
153
- async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}, retried = false): Promise<UiResult> {
241
+ async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number; page?: string } = {}, retried = false): Promise<UiResult> {
154
242
  if (this.closed) throw new UiControlError('unloaded', 'the door was unloaded')
155
243
  const readOnlyCall = request.op === 'snapshot' || request.op === 'wait'
244
+ if (options.page !== undefined) return await this.callPage(request, options.page, options, readOnlyCall)
245
+ if (this.requirePage) throw new UiControlError('no-window', 'this door drives only the page whose visitor asked, and the call names no page')
156
246
  // A page that is moving to another address registers again a moment later: a read-only call right after a navigate or reload waits for it, rather than failing.
157
247
  if (readOnlyCall && this.attachWaitMs > 0 && this.connections.size === 0) await this.expectNewPage(this.attachWaitMs)
158
248
  const ordered = this.ordered()
@@ -182,6 +272,26 @@ export class UiChannel {
182
272
  throw new UiControlError('timeout', `no page of the app answered within ${String(Math.round(this.ackMs / 1000))} s: the app's pages are open but none responded. Likely causes: a tab in the background, a page that is still loading after a navigation (look again), a page busy running a long script, or a page that holds the browser's six connections with event streams of its own (a page should keep to two). Bring the app's tab to the front, or navigate it, then look again`)
183
273
  }
184
274
 
275
+ /**
276
+ * A call for one page (the page a run was asked from, followed into its tab): it goes to that page alone and never fails over to another one. A page that is between two
277
+ * documents (it reloads, or moves to another path) is waited for, up to the attach wait.
278
+ */
279
+ private async callPage(request: UiRequest, page: string, options: { signal?: AbortSignal; timeoutMs?: number }, readOnly: boolean): Promise<UiResult> {
280
+ let target = this.connections.get(this.whereIs(page).windowId)
281
+ if (target === undefined && this.attachWaitMs > 0) {
282
+ const back = await this.expectPageBack(page, this.attachWaitMs)
283
+ if (back !== undefined) target = this.connections.get(back)
284
+ }
285
+ if (target === undefined) throw new UiControlError('no-window', 'the page this run is for is not open any more')
286
+ if (options.signal?.aborted === true) throw new UiControlError('aborted', 'the call was cancelled')
287
+ try {
288
+ return await this.callOne(target, request, options, readOnly)
289
+ } catch (cause) {
290
+ if (cause instanceof Silent) throw new UiControlError('timeout', 'the page this run is for did not answer: is its tab in the background or still loading? Look again')
291
+ throw cause
292
+ }
293
+ }
294
+
185
295
  private callOne(target: Connection, request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number }, failOver: boolean): Promise<UiResult> {
186
296
  const signal = options.signal
187
297
  const id = this.nextId
@@ -224,12 +334,15 @@ export class UiChannel {
224
334
 
225
335
  /**
226
336
  * A promise for the window id of the next page that connects and was not connected when this was asked (the page that came back after a reload or a move).
337
+ * @param signal - stop waiting; the promise then answers `undefined`.
227
338
  * @returns that id, or `undefined` when no new page connected within `timeoutMs`.
228
339
  */
229
- expectNewPage(timeoutMs: number): Promise<string | undefined> {
340
+ expectNewPage(timeoutMs: number, signal?: AbortSignal): Promise<string | undefined> {
230
341
  const known = new Set(this.connections.keys())
231
342
  return new Promise((resolve) => {
232
- const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); resolve(value) }
343
+ const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); signal?.removeEventListener('abort', stop); resolve(value) }
344
+ const stop = (): void => { done(undefined) }
345
+ signal?.addEventListener('abort', stop, { once: true })
233
346
  const waiter = (windowId: string): void => { if (!known.has(windowId)) done(windowId) }
234
347
  const timer = setTimeout(() => { done(undefined) }, timeoutMs)
235
348
  this.attachWaiters.add(waiter)
@@ -263,9 +376,11 @@ export class UiChannel {
263
376
  this.connections.clear()
264
377
  }
265
378
 
266
- /** The order pages are driven in: the one the Host sent somewhere, suspect ones last, the one in front, then the most recently active. */
379
+ /** The order pages are driven in: the one whose panel asked, the one the Host sent somewhere, suspect ones last, the one in front, then the most recently active. */
267
380
  private ordered(): Connection[] {
268
- const rank = (connection: Connection): number => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0)
381
+ // The latest asker that is still connected; a page that went away is skipped, and the run goes on with whatever is open.
382
+ const asker = [...this.askers].reverse().find(entry => this.connections.has(entry.windowId))?.windowId
383
+ const rank = (connection: Connection): number => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0) + (connection.windowId === asker && !connection.suspect ? 8 : 0)
269
384
  return [...this.connections.values()].sort((a, b) => rank(b) - rank(a) || b.lastActive - a.lastActive)
270
385
  }
271
386
 
@@ -6,10 +6,10 @@
6
6
 
7
7
  import { UiControlError } from '../page/protocol.ts'
8
8
  import { DoorError, type Door, type ToolsPort } from './door.ts'
9
- import { chatToolName, VERB_SPECS, VERBS } from './verbs.ts'
9
+ import { chatToolName, VERB_SPECS, VERBS, type Verb } from './verbs.ts'
10
10
 
11
- /** Register every verb. @returns the function that removes them all; it also aborts the calls still running. */
12
- export function registerChatTools(tools: ToolsPort, door: Door): () => void {
11
+ /** Register the verbs (default: every one). @returns the function that removes them all; it also aborts the calls still running. */
12
+ export function registerChatTools(tools: ToolsPort, door: Door, verbs: readonly Verb[] = VERBS): () => void {
13
13
  const running = new AbortController()
14
14
  const removers: Array<() => void> = []
15
15
  const removeAll = (): void => {
@@ -17,14 +17,16 @@ export function registerChatTools(tools: ToolsPort, door: Door): () => void {
17
17
  for (const remove of removers.splice(0).reverse()) remove()
18
18
  }
19
19
  try {
20
- for (const verb of VERBS) {
20
+ for (const verb of verbs) {
21
21
  removers.push(tools.register(chatToolName(verb), {
22
22
  description: VERB_SPECS[verb].description,
23
23
  // The model is told which fields the verb takes (an `act` needs its `op`), the same schema MCP lists.
24
24
  parameters: VERB_SPECS[verb].arguments as unknown as Readonly<Record<string, unknown>>,
25
- run: async (input: unknown) => {
25
+ // The run's own signal and page come with the call: its look and act go to the page the run was asked from.
26
+ run: async (input: unknown, options?: { signal?: AbortSignal; page?: string }) => {
27
+ const signal = options?.signal === undefined ? running.signal : AbortSignal.any([running.signal, options.signal])
26
28
  try {
27
- return (await door.run(verb, input ?? {}, running.signal)).text
29
+ return (await door.run(verb, input ?? {}, signal, options?.page === undefined ? {} : { page: options.page })).text
28
30
  } catch (cause) {
29
31
  if (cause instanceof DoorError || cause instanceof UiControlError) throw new Error(`${cause.code}: ${cause.message}`, { cause })
30
32
  throw cause
@@ -73,12 +73,17 @@ function alive(pid: number): boolean {
73
73
  * Write this run's connection file, replacing a stale one.
74
74
  * @throws when another live process already serves this folder's door: two apps from one folder would race for one file.
75
75
  */
76
- export function writeDoorFile(appRoot: string, url: string, secret: string): string {
77
- const dir = ensureStateDir(appRoot)
76
+ /** @throws when another live process already serves this folder's door: two apps from one folder would race for one file. */
77
+ export function assertDoorIsFree(appRoot: string): void {
78
78
  const existing = readDoorFile(appRoot)
79
79
  if (existing !== undefined && existing.pid !== process.pid && alive(existing.pid)) {
80
80
  throw new Error(`acryl-door: another process (pid ${String(existing.pid)}) serves the door of ${appRoot}; stop it first`)
81
81
  }
82
+ }
83
+
84
+ export function writeDoorFile(appRoot: string, url: string, secret: string): string {
85
+ const dir = ensureStateDir(appRoot)
86
+ assertDoorIsFree(appRoot)
82
87
  const file = doorFilePath(appRoot)
83
88
  const temporary = join(dir, `${DOOR_FILE}.${String(process.pid)}.tmp`)
84
89
  const content: DoorFile = { version: 1, url, secret, pid: process.pid }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { existsSync } from 'node:fs'
8
8
  import { isAbsolute, relative, resolve } from 'node:path'
9
+ import { underAppBase } from '../page/base.ts'
9
10
  import { parseUiRequest, UiControlError, type AuditEntry, type UiActionResult, type UiRequest, type UiResult, type UiSnapshot } from '../page/protocol.ts'
10
11
  import { renderSnapshot } from '../page/snapshot-text.ts'
11
12
  import type { AuditLog } from './audit.ts'
@@ -37,7 +38,7 @@ export interface ExtensionsPort {
37
38
  /** The slice of acrylblends' `tools` registry (the `agent` plugin's) the tool verbs use, and where the built-in chat finds the verbs. */
38
39
  export interface ToolsPort {
39
40
  /** @returns the function that removes the tool. @throws when the name is taken. */
40
- register(name: string, tool: { readonly description: string; readonly parameters?: Readonly<Record<string, unknown>>; run(input: unknown): unknown }): () => void
41
+ register(name: string, tool: { readonly description: string; readonly parameters?: Readonly<Record<string, unknown>>; run(input: unknown, options?: { signal?: AbortSignal; page?: string }): unknown }): () => void
41
42
  list(): ReadonlyArray<{ readonly name: string; readonly description: string }>
42
43
  call(name: string, input: unknown): Promise<unknown>
43
44
  }
@@ -49,6 +50,8 @@ export interface DoorDeps {
49
50
  readonly audit: AuditLog
50
51
  /** The app's name, folder and, when it listens, its address (what `brief` prints so an agent can curl the running app). */
51
52
  readonly app: { readonly name: string; readonly root: string; readonly url?: string | undefined }
53
+ /** The prefix the app's pages answer under (the frame's canvas, `/app`); absent on the app's own server, at the root. */
54
+ readonly appBase?: string | undefined
52
55
  /** Read at each call: the services may come and go while the door runs. */
53
56
  readonly extensions: () => ExtensionsPort | undefined
54
57
  readonly tools: () => ToolsPort | undefined
@@ -60,6 +63,11 @@ export interface DoorDeps {
60
63
  readonly pageBackMs?: number
61
64
  }
62
65
 
66
+ /** Who a verb runs for: `page`, the door's id of the page a run was asked from; its look and act go to that page alone (followed into its tab), never to another one. */
67
+ export interface VerbScope {
68
+ readonly page?: string
69
+ }
70
+
63
71
  /** What one verb answered: the value for `--json` and programs, the text for an agent reading a terminal. */
64
72
  export interface VerbAnswer {
65
73
  readonly value: unknown
@@ -90,17 +98,22 @@ export class Door {
90
98
  this.deps = deps
91
99
  }
92
100
 
101
+ /** A run asked for from the panel of this page is going: its verbs that name no window are for that page. @returns the function to call when the run ends. */
102
+ driveFor(windowId: string): () => void {
103
+ return this.deps.channel.driveFor(windowId)
104
+ }
105
+
93
106
  /** Run one verb. @throws DoorError or UiControlError. */
94
- async run(verb: string, args: unknown, signal: AbortSignal): Promise<VerbAnswer> {
107
+ async run(verb: string, args: unknown, signal: AbortSignal, scope: VerbScope = {}): Promise<VerbAnswer> {
95
108
  const input = (typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {}) as Record<string, unknown>
96
109
  switch (verb) {
97
110
  case 'brief': return this.brief()
98
111
  case 'look': {
99
- const snapshot = await this.look(input, signal)
112
+ const snapshot = await this.look(input, signal, scope)
100
113
  return { value: snapshot, text: renderSnapshot(snapshot, cursor => `run look --cursor ${String(cursor)}`) }
101
114
  }
102
115
  case 'act': {
103
- const result = await this.act(input, signal)
116
+ const result = await this.act(input, signal, scope)
104
117
  return { value: result, text: `done${result.target === undefined ? '' : `: ${result.target.role} "${result.target.name}"`}${result.detail === undefined ? '' : ` (${result.detail})`}; look again before the next act` }
105
118
  }
106
119
  case 'plugin-list': return this.plugin(extensions => extensions.list(), false)
@@ -146,30 +159,47 @@ export class Door {
146
159
  return { value, text }
147
160
  }
148
161
 
149
- look(input: Record<string, unknown>, signal: AbortSignal): Promise<UiSnapshot> {
150
- const { window, rest } = this.windowOf(input)
162
+ look(input: Record<string, unknown>, signal: AbortSignal, scope: VerbScope = {}): Promise<UiSnapshot> {
163
+ const { window, rest } = this.windowOf(input, scope)
151
164
  const request = this.parse({ ...rest, op: 'snapshot' })
152
- return this.callPage('look', request, signal, window) as Promise<UiSnapshot>
165
+ return this.callPage('look', request, signal, window, scope.page) as Promise<UiSnapshot>
153
166
  }
154
167
 
155
- async act(input: Record<string, unknown>, signal: AbortSignal): Promise<UiActionResult> {
168
+ async act(input: Record<string, unknown>, signal: AbortSignal, scope: VerbScope = {}): Promise<UiActionResult> {
156
169
  if (input.op === 'snapshot') throw new DoorError('invalid', 'use look to see the page')
157
- const { window, rest } = this.windowOf(input)
170
+ const { window, rest } = this.windowOf(input, scope)
158
171
  const request = this.parse(rest)
159
- if (request.op !== 'reload' && request.op !== 'navigate') return await this.callPage(`act ${String(input.op)}`, request, signal, window) as UiActionResult
160
- // The page is replaced: wait for the new one to connect, so the next look sees it. The page may drop before it has answered; that is the reload working.
161
- const back = this.deps.channel.expectNewPage(this.deps.pageBackMs ?? PAGE_BACK_MS)
172
+ const page = scope.page
173
+ // A reload or a navigate replaces the page; a click, a key or typing may (a link, a form that submits), and the page says so when it does.
174
+ const moves = request.op === 'reload' || request.op === 'navigate'
175
+ if (!moves && request.op !== 'click' && request.op !== 'press' && request.op !== 'type')
176
+ return await this.callPage(`act ${String(input.op)}`, request, signal, window, page) as UiActionResult
177
+ // Wait for the new page to connect, so the next look sees it; the wait starts before the act, or a fast page is missed. The page may drop before it has answered;
178
+ // that is the move working. A run's own page comes back in its own tab; no other page that happens to connect meanwhile is taken for it.
179
+ const backMs = this.deps.pageBackMs ?? PAGE_BACK_MS
180
+ const stay = new AbortController()
181
+ const back = page === undefined ? this.deps.channel.expectNewPage(backMs, stay.signal) : this.deps.channel.expectPageBack(page, backMs, stay.signal)
162
182
  let answer: UiActionResult
183
+ let left = moves
163
184
  try {
164
- answer = await this.callPage(`act ${request.op}`, request, signal, window) as UiActionResult
185
+ answer = await this.callPage(`act ${request.op}`, request, signal, window, page) as UiActionResult
186
+ left ||= answer.leaving === true
165
187
  } catch (cause) {
166
- if (!(cause instanceof UiControlError && cause.code === 'disconnected')) { void back; throw cause }
167
- answer = { ok: true, detail: request.op === 'reload' ? 'reloading' : `going to ${request.path}` }
188
+ if (!(cause instanceof UiControlError && cause.code === 'disconnected')) { stay.abort(); throw cause }
189
+ answer = { ok: true, detail: request.op === 'reload' ? 'reloading' : request.op === 'navigate' ? `going to ${request.path}` : 'the page is going to another address' }
190
+ left = true
191
+ }
192
+ if (!left) {
193
+ stay.abort()
194
+ return answer
168
195
  }
169
196
  const came = await back
170
- // The page that came back is the one this agent was driving: it stays the target until the person focuses another page.
171
- if (came !== undefined) this.deps.channel.prefer(came)
172
- if (came === undefined) throw new UiControlError('timeout', request.op === 'reload' ? 'the page did not come back after the reload' : `the page did not come back after going to ${request.path}: is there a page at that path, and does it load the door's script?`)
197
+ // The page that came back is the one this agent was driving: it stays the target until the person focuses another page. A run that names its page follows that page's tab instead.
198
+ if (came !== undefined && page === undefined) this.deps.channel.prefer(came)
199
+ if (came === undefined)
200
+ throw new UiControlError('timeout', request.op === 'reload' ? 'the page did not come back after the reload'
201
+ : request.op === 'navigate' ? `the page did not come back after going to ${request.path}: is there a page at that path, and does it load the door's script?`
202
+ : `the ${request.op} took the page to another address, and no page came back: does the page there load the door's script?`)
173
203
  const landed = request.op === 'navigate' ? await this.statusAt(request.path) : undefined
174
204
  return { ...answer, detail: `${answer.detail ?? 'done'}${landed === undefined ? '' : `; ${landed}`}; the page is back: look again` }
175
205
  }
@@ -182,7 +212,10 @@ export class Door {
182
212
  const base = this.deps.app.url
183
213
  if (base === undefined) return undefined
184
214
  try {
185
- const response = await fetch(new URL(path, base), { headers: { accept: 'text/html' }, signal: AbortSignal.timeout(3000) })
215
+ // The app's own path, under the host prefix when the pages answer there (the frame's canvas): /board is /app/board in the canvas and /board at the
216
+ // root, and the app's root is the prefix itself (`/app`, where its rows mount `/`), as the page's navigation join does it.
217
+ const target = underAppBase(this.deps.appBase ?? '', path)
218
+ const response = await fetch(new URL(target, base), { headers: { accept: 'text/html' }, signal: AbortSignal.timeout(3000) })
186
219
  await response.body?.cancel()
187
220
  return response.ok ? `${response.status} at ${path}` : `${response.status} at ${path}: the app serves no page there, look at what is shown and navigate to a path it does`
188
221
  } catch {
@@ -232,9 +265,11 @@ export class Door {
232
265
  }
233
266
 
234
267
  /** `window` on look and act: the desktop window (the number `window list` prints) whose page the call is for; without it, the page used last. */
235
- private windowOf(input: Record<string, unknown>): { window: number | undefined; rest: Record<string, unknown> } {
268
+ private windowOf(input: Record<string, unknown>, scope: VerbScope = {}): { window: number | undefined; rest: Record<string, unknown> } {
236
269
  const { window, ...rest } = input
237
270
  if (window === undefined) return { window: undefined, rest }
271
+ // A run that is for one page cannot be pointed at a desktop window instead.
272
+ if (scope.page !== undefined) throw new DoorError('invalid', 'this run drives only the page it was asked from; window does not apply')
238
273
  if (typeof window !== 'number' || !Number.isSafeInteger(window) || window < 1) throw new DoorError('invalid', 'window must be the number `window list` prints for the window')
239
274
  return { window, rest }
240
275
  }
@@ -248,10 +283,10 @@ export class Door {
248
283
  }
249
284
 
250
285
  /** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
251
- private async callPage(verb: string, request: UiRequest, signal: AbortSignal, window?: number): Promise<UiResult> {
286
+ private async callPage(verb: string, request: UiRequest, signal: AbortSignal, window?: number, page?: string): Promise<UiResult> {
252
287
  const approval: AuditEntry['approval'] = 'token'
253
288
  try {
254
- const value = await this.deps.channel.call(request, { signal, ...(window === undefined ? {} : { window }), ...(request.op === 'wait' ? { timeoutMs: (request.timeoutMs ?? 3000) + 5000 } : {}) })
289
+ const value = await this.deps.channel.call(request, { signal, ...(window === undefined ? {} : { window }), ...(page === undefined ? {} : { page }), ...(request.op === 'wait' ? { timeoutMs: (request.timeoutMs ?? 3000) + 5000 } : {}) })
255
290
  const target = 'target' in value ? value.target : undefined
256
291
  this.deps.audit.record({ at: new Date().toISOString(), tool: verb, ...(target === undefined ? {} : { target }), ...(window === undefined ? {} : { window }), outcome: 'ok', approval })
257
292
  return value
@@ -265,7 +300,7 @@ export class Door {
265
300
 
266
301
  private async plugin(run: (extensions: ExtensionsPort) => unknown, mutates = true): Promise<VerbAnswer> {
267
302
  const extensions = this.deps.extensions()
268
- if (extensions === undefined) throw new DoorError('unavailable', 'this app has no extensions plugin, so plugins cannot be installed while it runs')
303
+ if (extensions === undefined) throw new DoorError('unavailable', 'this door has no extensions plugin to drive. In ACRYL\'s frame none is needed: a saved change in src/ or plugins/ reloads the app by itself, so do not restart it. Elsewhere, the app has no way to install plugins while it runs')
269
304
  const value = await run(extensions)
270
305
  // The app changed under the open pages: reload them (not when the plugin was already as it should be), and answer once they are back.
271
306
  const unchanged = typeof value === 'object' && value !== null && (value as { action?: unknown }).action === 'unchanged'
@@ -34,6 +34,8 @@ export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions
34
34
  const focused = c.req.query('focused') === '1'
35
35
  const shellQuery = Number(c.req.query('shell'))
36
36
  const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : undefined
37
+ const tabQuery = c.req.query('tab')
38
+ const tab = tabQuery !== undefined && WINDOW_ID.test(tabQuery) ? tabQuery : undefined
37
39
  const signal = c.req.raw.signal
38
40
  return streamSSE(c, async (stream) => {
39
41
  let end = (): void => {}
@@ -43,7 +45,7 @@ export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions
43
45
  const write = (event: string, data: string): void => {
44
46
  stream.writeSSE({ event, data }).catch(end)
45
47
  }
46
- const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, close: end }, shellWindow)
48
+ const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, notify: write, close: end }, shellWindow, tab)
47
49
  const beat = setInterval(() => { write('ping', '') }, heartbeatMs)
48
50
  try {
49
51
  write('ready', '')
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { Acryl, type MiddlewareHandler } from 'acryl-router'
12
- import type { UpgradeWebSocket } from 'acryl-router/helpers'
12
+ import type { UpgradeWebSocket, WSEvents } from 'acryl-router/helpers'
13
13
  import { loopbackAuthority } from 'acryl-router/middleware'
14
14
  import { parseChannelToHost, WINDOW_ID } from '../page/protocol.ts'
15
15
  import type { UiChannel } from './channel.ts'
@@ -20,7 +20,8 @@ export const SOCKET_HEARTBEAT_MS = 15_000
20
20
 
21
21
  export interface PageSocketOptions {
22
22
  readonly prefix: string
23
- readonly upgrade: UpgradeWebSocket
23
+ /** The live upgrade helper: the route is mounted once, so the helper is read per request (a restarting host has a new one). */
24
+ readonly upgrade: () => UpgradeWebSocket | undefined
24
25
  readonly heartbeatMs?: number
25
26
  }
26
27
 
@@ -39,14 +40,13 @@ export const ownOrigin: MiddlewareHandler = async (c, next) => {
39
40
 
40
41
  export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions): Acryl {
41
42
  const heartbeatMs = options.heartbeatMs ?? SOCKET_HEARTBEAT_MS
42
- return new Acryl().get(
43
- `${options.prefix}/channel/ws`,
44
- ownOrigin,
45
- options.upgrade((c) => {
43
+ const events = (c: Parameters<MiddlewareHandler>[0]): WSEvents => {
46
44
  const windowId = c.req.query('window') as string
47
45
  const focused = c.req.query('focused') === '1'
48
46
  const shellQuery = Number(c.req.query('shell'))
49
47
  const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : undefined
48
+ const tabQuery = c.req.query('tab')
49
+ const tab = tabQuery !== undefined && WINDOW_ID.test(tabQuery) ? tabQuery : undefined
50
50
  let detach: (() => void) | undefined
51
51
  let beat: NodeJS.Timeout | undefined
52
52
  return {
@@ -55,7 +55,7 @@ export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions)
55
55
  ws.send(JSON.stringify(frame))
56
56
  }
57
57
  // A call that cannot be written fails at once (`disconnected`), and a socket that is gone is dropped by its close: the door never holds a call for it.
58
- detach = channel.attach(windowId, focused, { send: data => { write({ e: 'call', d: data }) }, close: () => { ws.close(1000, 'replaced') } }, shellWindow)
58
+ detach = channel.attach(windowId, focused, { send: data => { write({ e: 'call', d: data }) }, notify: (event, data) => { write({ e: event, d: data }) }, close: () => { ws.close(1000, 'replaced') } }, shellWindow, tab)
59
59
  write({ e: 'ready' })
60
60
  beat = setInterval(() => {
61
61
  try { write({ e: 'ping' }) } catch { /* the close event follows */ }
@@ -73,6 +73,16 @@ export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions)
73
73
  detach?.()
74
74
  },
75
75
  }
76
- }, { textOnly: true }),
76
+ }
77
+ // Mounted once, ahead of the private routes: the route must keep its precedence across a host restart, so the middleware asks for the live helper
78
+ // at request time and refuses cleanly while none is ready.
79
+ return new Acryl().get(
80
+ `${options.prefix}/channel/ws`,
81
+ ownOrigin,
82
+ async (c, next) => {
83
+ const allow = options.upgrade()
84
+ if (allow === undefined) return c.json(error('the page channel is not ready yet'), 503)
85
+ return allow(events, { textOnly: true })(c, next)
86
+ },
77
87
  )
78
88
  }
@@ -6,6 +6,9 @@
6
6
  import { MAX_SNAPSHOT_NODES, MAX_WAIT_MS } from '../page/protocol.ts'
7
7
 
8
8
  /** Every verb, in the order `brief` lists them. */
9
+ /** The verbs that only use the page: what a page-only door (a production assistant) offers. */
10
+ export const PAGE_VERBS = ['look', 'act'] as const satisfies readonly Verb[]
11
+
9
12
  export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call', 'window-list', 'window-open', 'window-focus', 'window-close', 'window-resize', 'menu-list', 'menu-click'] as const
10
13
  export type Verb = (typeof VERBS)[number]
11
14