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
@@ -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
@@ -96,16 +104,16 @@ export class Door {
96
104
  }
97
105
 
98
106
  /** Run one verb. @throws DoorError or UiControlError. */
99
- async run(verb: string, args: unknown, signal: AbortSignal): Promise<VerbAnswer> {
107
+ async run(verb: string, args: unknown, signal: AbortSignal, scope: VerbScope = {}): Promise<VerbAnswer> {
100
108
  const input = (typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {}) as Record<string, unknown>
101
109
  switch (verb) {
102
110
  case 'brief': return this.brief()
103
111
  case 'look': {
104
- const snapshot = await this.look(input, signal)
112
+ const snapshot = await this.look(input, signal, scope)
105
113
  return { value: snapshot, text: renderSnapshot(snapshot, cursor => `run look --cursor ${String(cursor)}`) }
106
114
  }
107
115
  case 'act': {
108
- const result = await this.act(input, signal)
116
+ const result = await this.act(input, signal, scope)
109
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` }
110
118
  }
111
119
  case 'plugin-list': return this.plugin(extensions => extensions.list(), false)
@@ -151,30 +159,47 @@ export class Door {
151
159
  return { value, text }
152
160
  }
153
161
 
154
- look(input: Record<string, unknown>, signal: AbortSignal): Promise<UiSnapshot> {
155
- 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)
156
164
  const request = this.parse({ ...rest, op: 'snapshot' })
157
- return this.callPage('look', request, signal, window) as Promise<UiSnapshot>
165
+ return this.callPage('look', request, signal, window, scope.page) as Promise<UiSnapshot>
158
166
  }
159
167
 
160
- async act(input: Record<string, unknown>, signal: AbortSignal): Promise<UiActionResult> {
168
+ async act(input: Record<string, unknown>, signal: AbortSignal, scope: VerbScope = {}): Promise<UiActionResult> {
161
169
  if (input.op === 'snapshot') throw new DoorError('invalid', 'use look to see the page')
162
- const { window, rest } = this.windowOf(input)
170
+ const { window, rest } = this.windowOf(input, scope)
163
171
  const request = this.parse(rest)
164
- if (request.op !== 'reload' && request.op !== 'navigate') return await this.callPage(`act ${String(input.op)}`, request, signal, window) as UiActionResult
165
- // 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.
166
- 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)
167
182
  let answer: UiActionResult
183
+ let left = moves
168
184
  try {
169
- 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
170
187
  } catch (cause) {
171
- if (!(cause instanceof UiControlError && cause.code === 'disconnected')) { void back; throw cause }
172
- 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
173
195
  }
174
196
  const came = await back
175
- // The page that came back is the one this agent was driving: it stays the target until the person focuses another page.
176
- if (came !== undefined) this.deps.channel.prefer(came)
177
- 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?`)
178
203
  const landed = request.op === 'navigate' ? await this.statusAt(request.path) : undefined
179
204
  return { ...answer, detail: `${answer.detail ?? 'done'}${landed === undefined ? '' : `; ${landed}`}; the page is back: look again` }
180
205
  }
@@ -187,7 +212,10 @@ export class Door {
187
212
  const base = this.deps.app.url
188
213
  if (base === undefined) return undefined
189
214
  try {
190
- 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) })
191
219
  await response.body?.cancel()
192
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`
193
221
  } catch {
@@ -237,9 +265,11 @@ export class Door {
237
265
  }
238
266
 
239
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. */
240
- 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> } {
241
269
  const { window, ...rest } = input
242
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')
243
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')
244
274
  return { window, rest }
245
275
  }
@@ -253,10 +283,10 @@ export class Door {
253
283
  }
254
284
 
255
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. */
256
- 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> {
257
287
  const approval: AuditEntry['approval'] = 'token'
258
288
  try {
259
- 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 } : {}) })
260
290
  const target = 'target' in value ? value.target : undefined
261
291
  this.deps.audit.record({ at: new Date().toISOString(), tool: verb, ...(target === undefined ? {} : { target }), ...(window === undefined ? {} : { window }), outcome: 'ok', approval })
262
292
  return value
@@ -270,7 +300,7 @@ export class Door {
270
300
 
271
301
  private async plugin(run: (extensions: ExtensionsPort) => unknown, mutates = true): Promise<VerbAnswer> {
272
302
  const extensions = this.deps.extensions()
273
- 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')
274
304
  const value = await run(extensions)
275
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.
276
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) }, notify: write, 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 }) }, notify: (event, data) => { write({ e: event, 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
 
@@ -18,8 +18,9 @@ import { clientModules } from 'acryl-node-server/client-modules'
18
18
  import { auditPath, AuditLog } from './host/audit.ts'
19
19
  import { UiChannel } from './host/channel.ts'
20
20
  import { registerChatTools } from './host/chat-tools.ts'
21
+ import { PAGE_VERBS, VERBS } from './host/verbs.ts'
21
22
  import { Door, type ExtensionsPort, type ToolsPort } from './host/door.ts'
22
- import { newSecret, removeDoorFile, stateDir, writeDoorFile } from './host/door-file.ts'
23
+ import { assertDoorIsFree, newSecret, removeDoorFile, stateDir, writeDoorFile } from './host/door-file.ts'
23
24
  import { DOOR_PREFIX, privateApi } from './host/http.ts'
24
25
  import { mcpRoutes } from './host/mcp-routes.ts'
25
26
  import { operatorRoutes } from './host/operator-routes.ts'
@@ -37,9 +38,10 @@ interface AppFolder {
37
38
  readonly name: string
38
39
  }
39
40
 
40
- /** The part of `acryl-node-server` the door reads: where the app listens. */
41
+ /** The address the carrier listens on, as the `acrylWeb` service gives it (host and port; DSH's webServer serves it today). */
41
42
  interface Listening {
42
- readonly url: string
43
+ readonly host: string
44
+ readonly port: number
43
45
  }
44
46
 
45
47
  declare module '@deepseek-ai/cordis' {
@@ -48,7 +50,17 @@ declare module '@deepseek-ai/cordis' {
48
50
  }
49
51
  }
50
52
 
51
- export const inject = ['router', 'appInstance', 'nodeServer']
53
+ // Two hosts serve the same door. On DSH (slice 1 of the IDE port) the carrier's address comes from `acrylWeb`, upgrades from `routerHttp`, and the page script
54
+ // goes into DSH's own index through the web server's index-inject hook (the framework's router.wrap never sees that page). On the plain node-server host the
55
+ // address and upgrades come from `nodeServer`, and `router.wrap` adds the script. Only `router` and `appInstance` are required; a host without the optional
56
+ // services simply takes the other path, so the door never stays PENDING beside a server that does not provide them.
57
+ export const inject = ['router', 'appInstance']
58
+
59
+ /** The services a host may provide in place of `acrylWeb`/`routerHttp` (the plain node-server host). */
60
+ interface NodeServerLike {
61
+ readonly url?: string
62
+ upgrades?: () => UpgradeWebSocket
63
+ }
52
64
 
53
65
  export interface Config {
54
66
  tools?: { expose?: string[] }
@@ -57,13 +69,34 @@ export interface Config {
57
69
  * run it in production (a packaged desktop app whose user drives it with an agent) says so here; `ACRYL_DOOR=off` still wins.
58
70
  */
59
71
  allowInProduction?: boolean
72
+ /**
73
+ * Page only, for a production assistant: the page channel and two chat tools, `door_look` and `door_act`, and nothing else - no operator routes, no MCP, no desktop
74
+ * shell channel, no `.acryl/` file, no plugin, tool or window verbs. The app's agent can then use the page for a visitor and cannot change the app. Runs in production
75
+ * without `allowInProduction`; `ACRYL_DOOR=off` still wins.
76
+ */
77
+ pageOnly?: boolean
60
78
  /** Reload the open pages after a live plugin change, so an agent that built a feature sees it on the page it has open. Default: true. */
61
79
  reloadOnChange?: boolean
80
+ /**
81
+ * Put the page script in the host's own index page (DSH's web UI) too, so an agent can drive that UI. Default: true. A host whose index is a frame
82
+ * around the app (the ACRYL Blends frame, whose centre shows the app's page) turns it off: the door then drives the app's page in the canvas, and an
83
+ * agent's `act navigate` or a reload after a plugin change never takes the person's tab out of the frame.
84
+ */
85
+ hostIndex?: boolean
86
+ /**
87
+ * The host prefix the app's pages are served under (the frame's canvas, `/app`); absent on the app's own server, where they are at the root. The
88
+ * door puts it on every page as `<meta name="acryl-app-base">`, and `act navigate` with an app path goes under it: a page the app serves at
89
+ * `/board` is reached at `/app/board` in the frame and at `/board` in production, and the same verb works in both.
90
+ */
91
+ appBase?: string
62
92
  }
63
93
 
64
94
  export const Config: z<Config> = z.object({
65
95
  allowInProduction: z.boolean().default(false).description('Run in production too (NODE_ENV=production); ACRYL_DOOR=off still turns it off.'),
96
+ pageOnly: z.boolean().default(false).description('Only the page and the look and act chat tools (a production assistant); runs in production.'),
66
97
  reloadOnChange: z.boolean().default(true).description('Reload the open pages after a plugin was added, removed or reloaded while the app runs.'),
98
+ hostIndex: z.boolean().default(true).description("Put the page script in the host's own index page too; off when that index is a frame around the app."),
99
+ appBase: z.string().default('').description('The prefix the app pages are served under (the frame canvas), without a trailing slash; empty at the root.'),
67
100
  tools: z.object({
68
101
  // Not a `door_` name: those are the door's own verbs in the registry, never offered back as app tools.
69
102
  expose: z.array(z.string().pattern(/^(?!door_)[A-Za-z][A-Za-z0-9_-]{0,63}$/u)).description('App tools (from the tools registry) the door offers; none by default.'),
@@ -107,23 +140,47 @@ export function notFoundPage(path: string, scriptTag: string): string {
107
140
  export function apply(ctx: Context, rawConfig: Config = {}): void {
108
141
  const config = Config(rawConfig)
109
142
  // Development only: nothing is started, mounted or written in production, or when switched off.
110
- if (process.env.ACRYL_DOOR === 'off' || (process.env.NODE_ENV === 'production' && config.allowInProduction !== true)) return
143
+ const pageOnly = config.pageOnly === true
144
+ if (process.env.ACRYL_DOOR === 'off' || (process.env.NODE_ENV === 'production' && config.allowInProduction !== true && !pageOnly)) return
111
145
  const app = ctx.get('appInstance') as unknown as AppFolder
112
- const server = ctx.get('nodeServer') as unknown as Listening
146
+ // The carrier, as the host provides it: DSH's `acrylWeb`, or the plain node server's own url. Read when used, never captured at apply time.
147
+ const carrier = (): Listening | undefined => {
148
+ const web = ctx.get('acrylWeb') as unknown as Listening | undefined
149
+ if (web !== undefined) return web
150
+ const node = ctx.get('nodeServer') as unknown as NodeServerLike | undefined
151
+ if (typeof node?.url !== 'string') return undefined
152
+ try {
153
+ const parsed = new URL(node.url)
154
+ return { host: parsed.hostname, port: Number(parsed.port) }
155
+ } catch {
156
+ return undefined
157
+ }
158
+ }
113
159
  if (typeof app?.root !== 'string') throw new Error('acryl-door: the appInstance service has no app folder (root)')
114
160
  const reportError = (operation: string, cause: unknown): void => {
115
161
  ctx.logger?.error?.(`acryl-door: failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`)
116
162
  }
163
+ const address: { url: string } = { url: '' }
164
+ // The rival check runs at activation, not in the deferred write: a folder another live process serves fails the row at once (the write waits for a bound port).
165
+ // A page-only door writes no file, so it has no rival to check.
166
+ if (!pageOnly) assertDoorIsFree(app.root)
117
167
  const secret = newSecret()
118
168
  // A look right after a navigate or reload waits up to two seconds for the page that is moving to register again, and a read-only call that no page acknowledged gives a page that just registered one more try.
119
- const channel = new UiChannel({ attachWaitMs: 2000 })
169
+ // A page-only door serves every visitor of the app on one channel: each call goes to the page of the visitor whose run made it, never to another visitor's page.
170
+ const channel = new UiChannel({ attachWaitMs: 2000, requirePage: pageOnly })
120
171
  const shell = new ShellChannel()
121
172
  const audit = new AuditLog(auditPath(stateDir(app.root)))
173
+ // Where the app's pages answer under this host (`/app` in the frame): `act navigate` sends an app path there, and a page may read it from the
174
+ // head. The app's own server has no prefix and no meta.
175
+ const rawBase = typeof config.appBase === 'string' ? config.appBase.trim() : ''
176
+ const appBase = rawBase.startsWith('/') && rawBase !== '/' ? rawBase.replace(/\/+$/u, '') : ''
122
177
  const door = new Door({
123
178
  channel,
124
179
  shell,
125
180
  audit,
126
- app: { name: app.name, root: app.root, url: loopbackUrl(server.url) },
181
+ // The address is read when it is used, not when the plugin applies: the carrier binds after the fiber starts (DSH's webServer reports its listening port only then).
182
+ app: { name: app.name, root: app.root, get url() { return address.url } },
183
+ ...(appBase === '' ? {} : { appBase }),
127
184
  extensions: () => ctx.get('extensions') as unknown as ExtensionsPort | undefined,
128
185
  tools: () => ctx.get('tools') as unknown as ToolsPort | undefined,
129
186
  expose: config.tools?.expose ?? [],
@@ -134,10 +191,11 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
134
191
  // One script tag for the page (`page.js`): it imports the module tree served below it.
135
192
  const pageModule = new Acryl()
136
193
  .get(`${DOOR_PREFIX}/page.js`, c => c.body(`import '${DOOR_PREFIX}/page/main.ts'\n`, 200, { 'content-type': 'text/javascript; charset=utf-8' }))
137
- .route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR }))
194
+ .route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR, mount: `${DOOR_PREFIX}/page` }))
138
195
  // The page script on every HTML page the app answers, whenever its plugin was mounted (the pages of a plugin that loaded at boot, before this one, included): a page an agent writes
139
- // can be looked at and driven without anyone adding the tag. A page that already carries it (the templates' own pages) is left as it is. Development only, like the rest of the door.
196
+ // can be looked at and driven without anyone adding the tag. A page that already carries it keeps one copy. Development only, like the rest of the door.
140
197
  const tag = `<script type="module" src="${DOOR_PREFIX}/page.js"></script>`
198
+ const meta = appBase === '' ? '' : `<meta name="acryl-app-base" content="${appBase}">`
141
199
  const addScript = async (c: { res: Response; req: { path: string; header(name: string): string | undefined } }, next: () => Promise<void>): Promise<void> => {
142
200
  await next()
143
201
  if (!(c.res.headers.get('content-type') ?? '').includes('text/html')) {
@@ -147,48 +205,127 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
147
205
  c.res = new Response(notFoundPage(c.req.path, tag), { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } })
148
206
  return
149
207
  }
150
- const html = await c.res.text()
208
+ let html = await c.res.text()
151
209
  const headers = new Headers(c.res.headers)
152
210
  headers.delete('content-length')
153
- const carries = html.includes(`${DOOR_PREFIX}/page.js`)
154
- c.res = new Response(carries ? html : html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag, { status: c.res.status, headers })
211
+ if (meta !== '' && !html.includes('name="acryl-app-base"'))
212
+ html = html.includes('</head>') ? html.replace('</head>', `${meta}</head>`) : meta + html
213
+ if (!html.includes(`${DOOR_PREFIX}/page.js`))
214
+ html = html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag
215
+ c.res = new Response(html, { status: c.res.status, headers })
155
216
  }
156
217
  const router = ctx.router as unknown as { wrap?: (middleware: typeof addScript) => unknown }
157
218
  // An app whose router copy is older than this plugin has no `wrap`: the middleware then covers the routes mounted after the door, as the agent panel's does.
158
219
  if (typeof router.wrap === 'function') router.wrap(addScript)
159
220
  else ctx.router.route('/', new Acryl().use('*', addScript as never))
160
- // The page channel also runs over a WebSocket, which is not one of the browser's six connections per host. Without the `ws` package or an adapter that upgrades, the pages use the stream.
161
- let upgrade: UpgradeWebSocket | undefined
162
- try {
163
- upgrade = (ctx as unknown as { nodeServer?: { upgrades?: () => UpgradeWebSocket } }).nodeServer?.upgrades?.()
164
- } catch (cause) {
165
- reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
221
+ // DSH's own index.html is not a framework route, so `router.wrap` never sees it: the tag goes in through the web server's index-inject hook instead. The
222
+ // tap is owned by this plugin's fiber, so unloading the door takes the tag with it, and the new activation's page routes answer under the new secret.
223
+ if (config.hostIndex !== false) ctx.inject(['webServer'], inner => {
224
+ inner.effect(() => {
225
+ const taps = inner.get('webServer') as unknown as { tapIndex?: (transform: (html: string) => string) => () => void } | undefined
226
+ if (typeof taps?.tapIndex !== 'function') return () => {}
227
+ return taps.tapIndex(html => {
228
+ if (html.includes(`${DOOR_PREFIX}/page.js`)) return html
229
+ return html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag
230
+ })
231
+ }, 'acryl-door: the page script in the host index')
232
+ })
233
+ // The page channel also runs over a WebSocket, which is not one of the browser's six connections per host. Without `ws` or an adapter that
234
+ // upgrades, the pages use the stream. The socket route is mounted ONCE here, ahead of the private routes, so it keeps its precedence when the host
235
+ // restarts; what moves is the upgrade helper it reads per request. While routerHttp exists its inject scope owns the prefix claim and sets the
236
+ // helper (Cordis disposes the claim exactly when the service goes away); the node server's hub is the helper only while routerHttp is absent.
237
+ // An upgrade while no helper is ready is refused 503 by the route, never answered twice.
238
+ let pageUpgrade: UpgradeWebSocket | undefined
239
+ let nodeUpgrade: UpgradeWebSocket | undefined
240
+ const endNodeOwner = (): void => {
241
+ if (pageUpgrade === nodeUpgrade) pageUpgrade = undefined
242
+ nodeUpgrade = undefined
243
+ }
244
+ const nodeFallback = (): void => {
245
+ if (ctx.get('routerHttp') !== undefined || nodeUpgrade !== undefined) return
246
+ try {
247
+ const allow = (ctx.get('nodeServer') as unknown as NodeServerLike | undefined)?.upgrades?.()
248
+ if (allow !== undefined) {
249
+ nodeUpgrade = allow
250
+ pageUpgrade = allow
251
+ }
252
+ } catch (cause) {
253
+ reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
254
+ }
166
255
  }
256
+ ctx.inject(['routerHttp'], (inner) => {
257
+ // The adapter is here: the node helper gives way, and this scope owns the prefix claim and the helper until Cordis disposes it with the service.
258
+ endNodeOwner()
259
+ const http = inner.get('routerHttp') as unknown as
260
+ | { upgrades?: () => UpgradeWebSocket; mount?: (prefix: string, options: { upgrade: string[] }) => () => void }
261
+ | undefined
262
+ try {
263
+ const allow = http?.upgrades?.()
264
+ if (allow === undefined || http === undefined || typeof http.mount !== 'function') return
265
+ pageUpgrade = allow
266
+ const remove = http.mount(DOOR_PREFIX, { upgrade: [`${DOOR_PREFIX}/channel/ws`] })
267
+ inner.effect(() => () => {
268
+ remove()
269
+ if (pageUpgrade === allow) pageUpgrade = undefined
270
+ nodeFallback()
271
+ }, 'acryl-door: the page socket via routerHttp')
272
+ } catch (cause) {
273
+ reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
274
+ }
275
+ })
276
+ ctx.inject(['nodeServer'], (inner) => {
277
+ inner.effect(() => {
278
+ nodeFallback()
279
+ const onService = inner.on as unknown as (name: string, listener: () => void) => () => void
280
+ const off = onService('internal/service', () => {
281
+ if (ctx.get('routerHttp') !== undefined) endNodeOwner()
282
+ else nodeFallback()
283
+ })
284
+ return () => {
285
+ off()
286
+ endNodeOwner()
287
+ }
288
+ }, 'acryl-door: the page socket on the node server')
289
+ })
290
+ ctx.router.route('/', pageSocketRoutes(channel, { prefix: DOOR_PREFIX, upgrade: () => pageUpgrade }))
167
291
  ctx.router.route('/', privateApi(
168
292
  DOOR_PREFIX,
169
293
  pageModule,
170
294
  pageChannelRoutes(channel, { prefix: DOOR_PREFIX }),
171
- ...(upgrade === undefined ? [] : [pageSocketRoutes(channel, { prefix: DOOR_PREFIX, upgrade })]),
172
- shellRoutes(DOOR_PREFIX, secret, shell),
173
- operatorRoutes(DOOR_PREFIX, secret, door, reportError),
174
- mcpRoutes(DOOR_PREFIX, secret, door, reportError),
295
+ ...(pageOnly ? [] : [
296
+ shellRoutes(DOOR_PREFIX, secret, shell),
297
+ operatorRoutes(DOOR_PREFIX, secret, door, reportError),
298
+ mcpRoutes(DOOR_PREFIX, secret, door, reportError),
299
+ ]),
175
300
  ))
176
301
  // The built-in chat's agent reads the `tools` registry: the verbs are in it while both are up.
177
302
  ctx.inject(['tools'], (inner) => {
178
- inner.effect(() => registerChatTools(inner.get('tools') as unknown as ToolsPort, door), 'acryl-door: the verbs in the tools registry')
303
+ inner.effect(() => registerChatTools(inner.get('tools') as unknown as ToolsPort, door, pageOnly ? PAGE_VERBS : VERBS), 'acryl-door: the verbs in the tools registry')
179
304
  })
180
305
  ctx.effect(function* () {
181
306
  yield ctx.reflect.provide('door', door)
182
- writeDoorFile(app.root, loopbackUrl(server.url), secret)
307
+ }, 'acryl-door: the door service')
308
+ // A page-only door leaves no file and no command for an operator.
309
+ if (pageOnly) return
310
+ // The carrier binds asynchronously (DSH's webServer, the node server before it): the address and the file are written once it reports a real port.
311
+ ctx.effect(async () => {
312
+ let bound = carrier()
313
+ for (let at = 0; at < 500 && (bound === undefined || !(bound.port > 0)); at += 1) {
314
+ await new Promise((done) => setTimeout(done, 10))
315
+ bound = carrier()
316
+ }
317
+ if (bound === undefined) throw new Error('acryl-door: no carrier bound the app (neither acrylWeb nor nodeServer answered)')
318
+ address.url = loopbackUrl(`http://${bound.host}:${String(bound.port)}`)
319
+ writeDoorFile(app.root, address.url, secret)
183
320
  // A shim that cannot be replaced (Windows, held open by a running `acryl`) leaves the old one in place; it must not stop the app from starting.
184
321
  try { writeShims(app.root, shimTarget()) } catch (cause) { reportError('write .acryl/bin', cause) }
185
- yield () => { removeDoorFile(app.root, secret) }
186
- }, 'acryl-door: the door service, .acryl/door.json and .acryl/bin/acryl')
322
+ return () => { removeDoorFile(app.root, secret) }
323
+ }, 'acryl-door: .acryl/door.json and .acryl/bin/acryl')
187
324
  }
188
325
 
189
326
  export { Door, DoorError, type VerbAnswer } from './host/door.ts'
190
- export { chatToolName, VERB_SPECS, VERBS, type Verb, type VerbSpec } from './host/verbs.ts'
327
+ export { chatToolName, PAGE_VERBS, VERB_SPECS, VERBS, type Verb, type VerbSpec } from './host/verbs.ts'
191
328
  export { mcpTools } from './host/mcp-routes.ts'
192
329
  export { DOOR_PREFIX } from './host/http.ts'
193
- export { doorFilePath, readDoorFile, STATE_DIR, type DoorFile } from './host/door-file.ts'
330
+ export { assertDoorIsFree, doorFilePath, readDoorFile, STATE_DIR, type DoorFile } from './host/door-file.ts'
194
331
  export type { CallResponse } from './host/operator-routes.ts'
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Where a host serves the app's pages under a prefix (the ACRYL frame's canvas, `/app`): the meta the door puts on each page, and where an app path
3
+ * lands under that prefix. Empty at the root, which is the app's own server.
4
+ */
5
+
6
+ /**
7
+ * The one thing this file needs from a page's document. Spelled without DOM types so the Host imports `underAppBase` from here too, instead of keeping
8
+ * a second copy of the join; a real Document satisfies it.
9
+ */
10
+ interface MetaHost {
11
+ querySelector(selectors: string): { getAttribute(name: string): string | null } | null
12
+ }
13
+
14
+ /** The app base a page carries (`<meta name="acryl-app-base">`, the host's prefix for the app); '' at the root. */
15
+ export function appBaseOf(document: MetaHost): string {
16
+ const content = document.querySelector('meta[name="acryl-app-base"]')?.getAttribute('content')?.trim() ?? ''
17
+ return content.startsWith('/') && content !== '/' ? content.replace(/\/+$/u, '') : ''
18
+ }
19
+
20
+ /**
21
+ * `path` (an app path, optionally with a query) under `base`: '/board' under '/app' is '/app/board'; the app's root is the base itself, so '/' is
22
+ * '/app' (its mount) and not '/app/'; without a base, the path as written.
23
+ */
24
+ export function underAppBase(base: string, path: string): string {
25
+ if (base === '') return path
26
+ const at = path.search(/[?#]/u)
27
+ const route = at === -1 ? path : path.slice(0, at)
28
+ const rest = at === -1 ? '' : path.slice(at)
29
+ if (route === base || route.startsWith(`${base}/`)) return path
30
+ if (route === '/' || route === '') return `${base}${rest}`
31
+ return `${base}${route.startsWith('/') ? '' : '/'}${route}${rest}`
32
+ }
@@ -27,6 +27,8 @@ const BACKOFF_MAX_MS = 10_000
27
27
 
28
28
  export interface PageChannelOptions {
29
29
  readonly windowId: string
30
+ /** The browser tab the page is in (kept across the tab's reloads and moves), so the Host follows a run's page into the next document of its tab. */
31
+ readonly tab?: string
30
32
  /** The desktop window this page is in (`AcrylWindow/<n>` in the user agent, set by the Electron shell), so the Host can drive it by the number `window list` prints. */
31
33
  readonly shellWindow?: number
32
34
  readonly focused: () => boolean
@@ -70,7 +72,7 @@ export class PageChannel {
70
72
 
71
73
  connect(): void {
72
74
  if (this.disposed || this.source !== undefined) return
73
- const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}${this.options.shellWindow === undefined ? '' : `&shell=${String(this.options.shellWindow)}`}`
75
+ const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}${this.options.shellWindow === undefined ? '' : `&shell=${String(this.options.shellWindow)}`}${this.options.tab === undefined ? '' : `&tab=${encodeURIComponent(this.options.tab)}`}`
74
76
  const viaSocket = this.socketAllowed
75
77
  this.socketReady = false
76
78
  const source: ChannelSource = viaSocket
@@ -147,6 +147,17 @@ export function valueOf(el: Element, role: string): string | undefined {
147
147
  return value === undefined || value === '' ? undefined : clip(value, 100)
148
148
  }
149
149
 
150
+ /** Choices of a select listed at most; a longer list ends with how many more there are. */
151
+ const MAX_OPTIONS = 20
152
+
153
+ /** The choices of a native select (what `act select` takes), or undefined when it is not one or is sensitive. */
154
+ export function optionsOf(el: Element): string[] | undefined {
155
+ if (!(el instanceof HTMLSelectElement) || isSensitive(el)) return undefined
156
+ const labels = [...el.options].filter(option => !option.hidden).map(option => clip((option.textContent ?? '').trim(), 60)).filter(label => label !== '')
157
+ if (labels.length <= MAX_OPTIONS) return labels
158
+ return [...labels.slice(0, MAX_OPTIONS), `(${String(labels.length - MAX_OPTIONS)} more)`]
159
+ }
160
+
150
161
  /** Heading level, when the element is a heading. */
151
162
  export function levelOf(el: Element): number | undefined {
152
163
  const explicit = Number(el.getAttribute('aria-level'))