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/lib/door/page.js CHANGED
@@ -84,7 +84,7 @@ var PageChannel = class {
84
84
  }
85
85
  connect() {
86
86
  if (this.disposed || this.source !== void 0) return;
87
- const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? "1" : "0"}${this.options.shellWindow === void 0 ? "" : `&shell=${String(this.options.shellWindow)}`}`;
87
+ const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? "1" : "0"}${this.options.shellWindow === void 0 ? "" : `&shell=${String(this.options.shellWindow)}`}${this.options.tab === void 0 ? "" : `&tab=${encodeURIComponent(this.options.tab)}`}`;
88
88
  const viaSocket = this.socketAllowed;
89
89
  this.socketReady = false;
90
90
  const source = viaSocket ? (this.options.createSocket ?? ((url) => socketSource(new WebSocket(url))))(`${socketBase(this.options.base)}/channel/ws?${query}`) : (this.options.createSource ?? ((url) => new EventSource(url)))(`${this.options.base}/channel?${query}`);
@@ -99,6 +99,10 @@ var PageChannel = class {
99
99
  heard();
100
100
  });
101
101
  source.addEventListener("ping", heard);
102
+ source.addEventListener("run", (event) => {
103
+ heard();
104
+ this.options.onRun?.(event.data === "1");
105
+ });
102
106
  source.addEventListener("call", (event) => {
103
107
  heard();
104
108
  if (typeof event.data === "string") this.answer(event.data);
@@ -170,6 +174,7 @@ var PageChannel = class {
170
174
  if (source !== void 0) {
171
175
  source.onerror = null;
172
176
  source.close();
177
+ this.options.onRun?.(false);
173
178
  }
174
179
  }
175
180
  answer(text) {
@@ -492,6 +497,15 @@ function valueOf(el, role) {
492
497
  else if (el.hasAttribute("contenteditable")) value = el.textContent ?? void 0;
493
498
  return value === void 0 || value === "" ? void 0 : clip(value, 100);
494
499
  }
500
+ /** Choices of a select listed at most; a longer list ends with how many more there are. */
501
+ const MAX_OPTIONS = 20;
502
+ /** The choices of a native select (what `act select` takes), or undefined when it is not one or is sensitive. */
503
+ function optionsOf(el) {
504
+ if (!(el instanceof HTMLSelectElement) || isSensitive(el)) return void 0;
505
+ const labels = [...el.options].filter((option) => !option.hidden).map((option) => clip((option.textContent ?? "").trim(), 60)).filter((label) => label !== "");
506
+ if (labels.length <= MAX_OPTIONS) return labels;
507
+ return [...labels.slice(0, MAX_OPTIONS), `(${String(labels.length - MAX_OPTIONS)} more)`];
508
+ }
495
509
  /** Heading level, when the element is a heading. */
496
510
  function levelOf(el) {
497
511
  const explicit = Number(el.getAttribute("aria-level"));
@@ -578,7 +592,7 @@ var SnapshotBuilder = class {
578
592
  const root = this.root();
579
593
  const view = root.ownerDocument.defaultView;
580
594
  let offset = options.cursor ?? 0;
581
- if (options.cursor === void 0) {
595
+ if (offset === 0) {
582
596
  this.refs.begin();
583
597
  const all = collect(root);
584
598
  this.candidates = [...all.filter((c) => inViewport(c.element, view)), ...all.filter((c) => !inViewport(c.element, view))];
@@ -590,6 +604,7 @@ var SnapshotBuilder = class {
590
604
  if (isProtected(candidate.element)) states.push("protected");
591
605
  const value = valueOf(candidate.element, candidate.role);
592
606
  const level = candidate.role === "heading" ? levelOf(candidate.element) : void 0;
607
+ const options = optionsOf(candidate.element);
593
608
  return {
594
609
  ref: this.refs.issue(candidate.element, candidate.role, candidate.name),
595
610
  role: candidate.role,
@@ -597,7 +612,8 @@ var SnapshotBuilder = class {
597
612
  depth: candidate.depth,
598
613
  states,
599
614
  ...value === void 0 ? {} : { value },
600
- ...level === void 0 ? {} : { level }
615
+ ...level === void 0 ? {} : { level },
616
+ ...options === void 0 ? {} : { options }
601
617
  };
602
618
  });
603
619
  const end = offset + page.length;
@@ -640,6 +656,7 @@ function prepare(env, ref) {
640
656
  if (isProtected(element)) throw new UiControlError("protected", `"${resolved.name}" belongs to approval, permissions or Agent Control itself; only the user operates it`);
641
657
  if (effectivelyHidden(element)) throw new UiControlError("not-actionable", `"${resolved.name}" is not visible`);
642
658
  if (element.disabled === true || element.getAttribute("aria-disabled") === "true") throw new UiControlError("not-actionable", `"${resolved.name}" is disabled`);
659
+ env.touched?.(element);
643
660
  return resolved;
644
661
  }
645
662
  function mouse(element, type) {
@@ -781,6 +798,7 @@ function scroll(env, request) {
781
798
  const dy = request.direction === "up" ? -amount : request.direction === "down" ? amount : 0;
782
799
  if (request.ref !== void 0) {
783
800
  const { element, role, name } = env.refs.resolve(request.ref);
801
+ env.touched?.(element);
784
802
  element.scrollBy?.({
785
803
  left: dx,
786
804
  top: dy
@@ -909,6 +927,8 @@ var RefTable = class {
909
927
  /** After the user touches the page, agent actions wait this long, so the user always wins. */
910
928
  const YIELD_MS = 400;
911
929
  const MAX_YIELD_MS = 2e3;
930
+ /** How long after an act a navigation it started is still noticed (a link's navigation starts a task or two after the click). */
931
+ const LEAVE_WATCH_MS = 50;
912
932
  function realSleep(ms, signal) {
913
933
  return new Promise((resolve, reject) => {
914
934
  if (signal.aborted) {
@@ -938,9 +958,11 @@ var UiDriver = class {
938
958
  doing = "";
939
959
  current = {
940
960
  busy: false,
961
+ running: false,
941
962
  doing: "",
942
963
  killed: false
943
964
  };
965
+ runningFlag = false;
944
966
  options;
945
967
  constructor(options) {
946
968
  this.options = options;
@@ -948,7 +970,8 @@ var UiDriver = class {
948
970
  this.env = {
949
971
  refs: this.refs,
950
972
  document: options.document,
951
- sleep: options.sleep ?? realSleep
973
+ sleep: options.sleep ?? realSleep,
974
+ ...options.touched === void 0 ? {} : { touched: options.touched }
952
975
  };
953
976
  }
954
977
  /** The current activity; the same object until something changes, as a store snapshot must be. */
@@ -969,6 +992,16 @@ var UiDriver = class {
969
992
  this.snapshots.clear();
970
993
  this.emit();
971
994
  }
995
+ /**
996
+ * A run asked for from this page's panel starts (`true`) or ends (`false`). The indicator stays up for the whole run, not only while a call is in flight. A new run is a new
997
+ * question from the person, so it also allows the agent to drive again after an earlier Stop.
998
+ */
999
+ setRunning(running) {
1000
+ if (running === this.runningFlag) return;
1001
+ this.runningFlag = running;
1002
+ if (running) this.killedFlag = false;
1003
+ this.emit();
1004
+ }
972
1005
  /** The user allowed the agent to drive again. */
973
1006
  resume() {
974
1007
  this.killedFlag = false;
@@ -1017,10 +1050,10 @@ var UiDriver = class {
1017
1050
  ...request.cursor === void 0 ? {} : { cursor: request.cursor },
1018
1051
  ...request.maxNodes === void 0 ? {} : { maxNodes: request.maxNodes }
1019
1052
  });
1020
- case "click": return click(this.env, request);
1021
- case "type": return typeText(this.env, request);
1053
+ case "click": return await this.watchLeaving(() => click(this.env, request), signal);
1054
+ case "type": return await this.watchLeaving(() => typeText(this.env, request), signal);
1022
1055
  case "select": return selectOption(this.env, request);
1023
- case "press": return pressKey(this.env, request);
1056
+ case "press": return await this.watchLeaving(() => pressKey(this.env, request), signal);
1024
1057
  case "scroll": return scroll(this.env, request);
1025
1058
  case "wait": return wait(this.env, request, signal);
1026
1059
  case "reload": return this.leave({ reload: true }, "reloading");
@@ -1028,6 +1061,29 @@ var UiDriver = class {
1028
1061
  }
1029
1062
  }
1030
1063
  /**
1064
+ * Run an act that may start a navigation (a click on a link, Enter in a form) and say so: the browser asks `beforeunload` when a navigation starts, during the act or a
1065
+ * moment after it. The answer is sent before the page goes; the Host then waits for the next page instead of reading this one again.
1066
+ */
1067
+ async watchLeaving(act, signal) {
1068
+ const view = this.options.document().defaultView;
1069
+ let leaving = false;
1070
+ const onLeave = () => {
1071
+ leaving = true;
1072
+ };
1073
+ view?.addEventListener("beforeunload", onLeave);
1074
+ try {
1075
+ const result = act();
1076
+ if (!leaving) await this.env.sleep(LEAVE_WATCH_MS, signal);
1077
+ return leaving ? {
1078
+ ...result,
1079
+ leaving: true,
1080
+ detail: `${result.detail === void 0 ? "" : `${result.detail}; `}the page is going to another address`
1081
+ } : result;
1082
+ } finally {
1083
+ view?.removeEventListener("beforeunload", onLeave);
1084
+ }
1085
+ }
1086
+ /**
1031
1087
  * The page is about to be replaced, so the answer is given first and the move happens a moment later (the answer is a POST that the page's own unload would cut off).
1032
1088
  * The Host does not wait for this answer to be the last word: it waits for the page to come back.
1033
1089
  */
@@ -1058,10 +1114,11 @@ var UiDriver = class {
1058
1114
  emit() {
1059
1115
  const next = {
1060
1116
  busy: this.busyCount > 0,
1117
+ running: this.runningFlag,
1061
1118
  doing: this.doing,
1062
1119
  killed: this.killedFlag
1063
1120
  };
1064
- if (next.busy === this.current.busy && next.doing === this.current.doing && next.killed === this.current.killed) return;
1121
+ if (next.busy === this.current.busy && next.running === this.current.running && next.doing === this.current.doing && next.killed === this.current.killed) return;
1065
1122
  this.current = next;
1066
1123
  const activity = next;
1067
1124
  for (const listener of [...this.listeners]) listener(activity);
@@ -1111,6 +1168,8 @@ const LABELS = {
1111
1168
  scroll: "scrolling",
1112
1169
  wait: "waiting"
1113
1170
  };
1171
+ /** Dispatched on the page's window by the Stop button: the agent panel in the same page cancels its run. */
1172
+ const STOP_RUN_EVENT = "acryl:stop-run";
1114
1173
  const STYLE_ID = "acryl-door-indicator-style";
1115
1174
  const STYLES = `
1116
1175
  .acrylDoorBar { position: fixed; z-index: 2147483647; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px;
@@ -1133,12 +1192,16 @@ function mountIndicator(driver, document) {
1133
1192
  bar.setAttribute("data-acryl-no-agent", "");
1134
1193
  bar.setAttribute("role", "status");
1135
1194
  bar.setAttribute("aria-live", "polite");
1195
+ const stopAgent = () => {
1196
+ driver.kill();
1197
+ document.defaultView?.dispatchEvent(new CustomEvent(STOP_RUN_EVENT));
1198
+ };
1136
1199
  const onKey = (event) => {
1137
- if (event.key === "Escape" && event.isTrusted && driver.activity.busy) driver.kill();
1200
+ if (event.key === "Escape" && event.isTrusted && (driver.activity.busy || driver.activity.running)) stopAgent();
1138
1201
  };
1139
1202
  document.addEventListener("keydown", onKey, true);
1140
1203
  const render = (activity) => {
1141
- if (!activity.busy && !activity.killed) {
1204
+ if (!activity.busy && !activity.running && !activity.killed) {
1142
1205
  bar.remove();
1143
1206
  return;
1144
1207
  }
@@ -1158,11 +1221,9 @@ function mountIndicator(driver, document) {
1158
1221
  const dot = document.createElement("span");
1159
1222
  dot.className = "acrylDoorDot";
1160
1223
  dot.setAttribute("aria-hidden", "true");
1161
- text.textContent = `Agent is driving: ${LABELS[activity.doing] ?? activity.doing}`;
1224
+ text.textContent = `Agent is driving: ${activity.busy ? LABELS[activity.doing] ?? activity.doing : "working on your request"}`;
1162
1225
  button.textContent = "Stop";
1163
- button.addEventListener("click", () => {
1164
- driver.kill();
1165
- });
1226
+ button.addEventListener("click", stopAgent);
1166
1227
  bar.replaceChildren(dot, text, button);
1167
1228
  }
1168
1229
  if (!bar.isConnected) document.body.append(bar);
@@ -1,2 +1,2 @@
1
- import { C as parseUiRequest, S as parseChannelToPage, _ as UiRequest, a as DEFAULT_SNAPSHOT_NODES, b as WINDOW_ID, c as MAX_WAIT_MS, d as UI_OPS, f as UiActionResult, g as UiOp, h as UiNode, i as ChannelToPage, l as MUTATING_OPS, m as UiErrorCode, n as AuditEntry, o as MAX_SNAPSHOT_NODES, p as UiControlError, r as ChannelToHost, s as MAX_TEXT_LENGTH, t as APP_PATH, u as UI_ERROR_CODES, v as UiResult, x as parseChannelToHost, y as UiSnapshot } from "../protocol-a2aqrp2X.js";
1
+ import { C as parseUiRequest, S as parseChannelToPage, _ as UiRequest, a as DEFAULT_SNAPSHOT_NODES, b as WINDOW_ID, c as MAX_WAIT_MS, d as UI_OPS, f as UiActionResult, g as UiOp, h as UiNode, i as ChannelToPage, l as MUTATING_OPS, m as UiErrorCode, n as AuditEntry, o as MAX_SNAPSHOT_NODES, p as UiControlError, r as ChannelToHost, s as MAX_TEXT_LENGTH, t as APP_PATH, u as UI_ERROR_CODES, v as UiResult, x as parseChannelToHost, y as UiSnapshot } from "../protocol-BeIYCgTo.js";
2
2
  export { APP_PATH, AuditEntry, ChannelToHost, ChannelToPage, DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES, MAX_TEXT_LENGTH, MAX_WAIT_MS, MUTATING_OPS, UI_ERROR_CODES, UI_OPS, UiActionResult, UiControlError, UiErrorCode, UiNode, UiOp, UiRequest, UiResult, UiSnapshot, WINDOW_ID, parseChannelToHost, parseChannelToPage, parseUiRequest };
package/lib/drivers.d.ts CHANGED
@@ -26,10 +26,11 @@ type ChatEvent = {
26
26
  interface ChatDriver {
27
27
  readonly kind: 'chat';
28
28
  readonly name: string;
29
- /** Runs one message. Aborting `signal` stops the run. */
29
+ /** Runs one message. Aborting `signal` stops the run. `page` is the door's id of the page whose panel asked, when it sent one: a driver that uses the page drives that one. */
30
30
  start(options: {
31
31
  message: string;
32
32
  signal: AbortSignal;
33
+ page?: string;
33
34
  }): AsyncIterable<ChatEvent>;
34
35
  }
35
36
  /** A live pseudo-terminal session. */
@@ -45,6 +46,14 @@ interface TerminalSession {
45
46
  interface TerminalDriver {
46
47
  readonly kind: 'terminal';
47
48
  readonly name: string;
49
+ /**
50
+ * Where the panel loads the terminal view from: the browser module that draws it, and the URL prefix the view's endpoints live under. The driver
51
+ * plugin serves both, so the view follows wherever the app is mounted (the root of its own server, or the prefix the ACRYL frame serves it at).
52
+ */
53
+ readonly view: {
54
+ readonly module: string;
55
+ readonly prefix: string;
56
+ };
48
57
  start(options: {
49
58
  cwd: string;
50
59
  env: NodeJS.ProcessEnv;
@@ -88,7 +88,17 @@ declare class Router extends Service {
88
88
  * mount's code a request is in. The core's `app.route` runs first so a sub-app's own `onError` still applies to its routes.
89
89
  */
90
90
  private snapshot;
91
- /** Abort `flights`, cut their bodies short and wait for them to settle, at most `disposeGraceMs`. */
91
+ /**
92
+ * The router's own disposal: let the flights already in flight finish within `disposeGraceMs` - a save being written when the app stops must not be cut
93
+ * by the router beneath it - then abort what is left and settle it for a moment, so disposal does not wait a second full grace on code that ignores
94
+ * its abort signal.
95
+ */
96
+ private drain;
97
+ /** The one draining rule: let `flights` finish inside the grace, then abort and settle what is left. */
98
+ private drainFlights;
99
+ /** Resolve true when every flight settled inside `ms`, false at the deadline. */
100
+ private settledWithin;
101
+ /** Abort `flights`, cut their bodies short and wait for them to settle, at most `settleMs`. */
92
102
  private end;
93
103
  /** Turn errors no mounted app handled into responses. One handler per router; the calling plugin owns it. */
94
104
  onError(handler: ErrorHandler): () => void;
@@ -1,5 +1,5 @@
1
1
  import { t as Acryl } from "./app-D8riatvv.js";
2
- import { a as Owner, c as UpgradeHubOptions, d as upgradeWebSocket, f as WebSocketData, i as testWebSocket, l as UpgradeRouter, m as WebSocketServerLike, n as TestWebSocketOptions, o as UpgradeEmitter, p as WebSocketLike, r as TestWebSocketResult, s as UpgradeHub, t as TestSocket, u as RequestError } from "./test-websocket-DyeS50Tr.js";
2
+ import { a as Owner, c as UpgradeHubOptions, d as upgradeWebSocket, f as WebSocketData, i as testWebSocket, l as UpgradeRouter, m as WebSocketServerLike, n as TestWebSocketOptions, o as UpgradeEmitter, p as WebSocketLike, r as TestWebSocketResult, s as UpgradeHub, t as TestSocket, u as RequestError } from "./test-websocket-GUTAkFQT.js";
3
3
  import { n as serveStatic, t as ServeStaticOptions } from "./serve-static-B7sqnftW.js";
4
4
  import { t as getConnInfo } from "./conninfo-BCROmdnf.js";
5
5
  import { AddressInfo } from "node:net";
@@ -90,6 +90,12 @@ declare const getRequestListener: (fetchCallback: FetchCallback, options?: {
90
90
  interface ClientModulesOptions {
91
91
  /** The folder of client modules, absolute or relative to the working directory. */
92
92
  readonly root: string;
93
+ /**
94
+ * The URL prefix the app is mounted at, when the caller knows it. A host can compose app plugins under its own router, and under a
95
+ * Host whose router is a different copy of this package the request's match result is not visible here, so `basePath(c)` throws; the
96
+ * explicit prefix is then the only reliable way to know where the mount starts. Omitted, the router's `basePath(c)` is used.
97
+ */
98
+ readonly mount?: string | undefined;
93
99
  /** The folder {@link buildClientModules} wrote; with `NODE_ENV=production` it is served instead of compiling, which needs no `typescript`. */
94
100
  readonly compiled?: string | undefined;
95
101
  /** At most this many bytes of compiled modules are kept in memory (least recently served first out); 16 MiB when not given. */
@@ -1,5 +1,5 @@
1
- import { a as serve, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, o as upgradeWebSocket, r as UpgradeHub, s as getRequestListener, t as testWebSocket } from "./test-websocket-BLeEvybj.js";
1
+ import { a as serve, d as RequestError, i as createAdaptorServer, n as UpgradeEmitter, o as upgradeWebSocket, r as UpgradeHub, s as getRequestListener, t as testWebSocket } from "./test-websocket-CQOSvBth.js";
2
2
  import { t as serveStatic } from "./serve-static-C1Nkj_xB.js";
3
3
  import { t as getConnInfo } from "./conninfo-CWJwieh4.js";
4
- import { n as clientModules, t as buildClientModules } from "./client-modules-drj4mjhj.js";
4
+ import { n as clientModules, t as buildClientModules } from "./client-modules-Bkdr83jO.js";
5
5
  export { RequestError, UpgradeEmitter, UpgradeHub, buildClientModules, clientModules, createAdaptorServer, getConnInfo, getRequestListener, serve, serveStatic, testWebSocket, upgradeWebSocket };
@@ -36,6 +36,8 @@ interface UiNode {
36
36
  readonly value?: string;
37
37
  /** Heading level, when it is a heading. */
38
38
  readonly level?: number;
39
+ /** The choices of a select, as `act select` takes them, when it is not sensitive. */
40
+ readonly options?: readonly string[];
39
41
  }
40
42
  interface UiSnapshot {
41
43
  readonly generation: number;
@@ -93,6 +95,8 @@ interface UiActionResult {
93
95
  readonly name: string;
94
96
  };
95
97
  readonly detail?: string;
98
+ /** The act started a navigation (a link, a form that submits): this page is going away, and the Host waits for the next one. */
99
+ readonly leaving?: true;
96
100
  }
97
101
  type UiResult = UiSnapshot | UiActionResult;
98
102
  /**
@@ -5,7 +5,7 @@ import { r as streamSSE } from "./streaming-BlDaph8U.js";
5
5
  import { i as loopbackOnly, r as loopbackAuthority } from "./loopback-only-BbasoF7R.js";
6
6
  import "./helpers-BEjcdbgw.js";
7
7
  import { d as parseChannelToHost, l as UiControlError, u as WINDOW_ID } from "./protocol-CQ_if7Dg.js";
8
- import "./src-BTBm4OPD.js";
8
+ import "./src-iBJ2ysV9.js";
9
9
  import "./middleware-BfG_fp8P.js";
10
10
  import { dirname, join } from "node:path";
11
11
  import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from "node:fs";
@@ -29,17 +29,22 @@ const DEFAULT_CALL_TIMEOUT_MS = 3e4;
29
29
  /** How long a page has to say it heard a read-only call before the call goes to the next page. */
30
30
  const DEFAULT_ACK_MS = 3e3;
31
31
  /**
32
- * 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
33
- * 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.
32
+ * 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
33
+ * 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.
34
+ * 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.
34
35
  */
35
36
  var UiChannel = class {
36
37
  ackMs;
37
38
  attachWaitMs;
39
+ requirePage;
38
40
  /** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
39
41
  preferred;
42
+ /** 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. */
43
+ askers = [];
40
44
  constructor(options = {}) {
41
45
  this.ackMs = options.ackMs ?? DEFAULT_ACK_MS;
42
46
  this.attachWaitMs = options.attachWaitMs ?? 0;
47
+ this.requirePage = options.requirePage === true;
43
48
  }
44
49
  connections = /* @__PURE__ */ new Map();
45
50
  pending = /* @__PURE__ */ new Map();
@@ -52,18 +57,22 @@ var UiChannel = class {
52
57
  * Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
53
58
  * @returns the function to call when this stream ends.
54
59
  */
55
- attach(windowId, focused, stream, shellWindow) {
60
+ attach(windowId, focused, stream, shellWindow, tab) {
56
61
  const previous = this.connections.get(windowId);
57
62
  if (previous !== void 0) this.drop(previous, true);
58
63
  const connection = {
59
64
  stream,
60
65
  windowId,
61
66
  shellWindow,
67
+ tab,
62
68
  focused,
63
69
  lastActive: ++this.clock,
64
70
  suspect: previous?.suspect === true
65
71
  };
66
72
  this.connections.set(windowId, connection);
73
+ for (const asker of this.askers) if (asker.windowId === windowId) asker.tab ??= tab;
74
+ else if (tab !== void 0 && asker.tab === tab) asker.windowId = windowId;
75
+ if (this.askers.some((asker) => asker.windowId === windowId)) this.tell(connection, "1");
67
76
  for (const waiter of [...this.attachWaiters]) waiter(windowId);
68
77
  return () => {
69
78
  if (this.connections.get(windowId) === connection) this.drop(connection, false);
@@ -104,12 +113,83 @@ var UiChannel = class {
104
113
  ...shellWindow === void 0 ? {} : { shellWindow }
105
114
  }));
106
115
  }
116
+ /**
117
+ * 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.
118
+ * An explicit desktop `window` still picks its own page.
119
+ * @returns the function to call when the run ends.
120
+ */
121
+ driveFor(windowId) {
122
+ const asker = {
123
+ origin: windowId,
124
+ windowId,
125
+ tab: this.connections.get(windowId)?.tab
126
+ };
127
+ this.askers.push(asker);
128
+ this.tellPage(windowId, "1");
129
+ return () => {
130
+ const at = this.askers.indexOf(asker);
131
+ if (at === -1) return;
132
+ this.askers.splice(at, 1);
133
+ if (!this.askers.some((other) => other.windowId === asker.windowId)) this.tellPage(asker.windowId, "0");
134
+ };
135
+ }
136
+ /** Where the page a run was asked from is now: the same window, or the page that replaced it in the same tab. */
137
+ whereIs(page) {
138
+ const asker = [...this.askers].reverse().find((entry) => entry.origin === page);
139
+ return asker === void 0 ? {
140
+ windowId: page,
141
+ tab: this.connections.get(page)?.tab
142
+ } : {
143
+ windowId: asker.windowId,
144
+ tab: asker.tab
145
+ };
146
+ }
147
+ /**
148
+ * 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
149
+ * leave. Pages of other tabs, another visitor's among them, are never taken for it.
150
+ * @param signal - stop waiting (the act did not move the page after all); the promise then answers `undefined`.
151
+ * @returns the window id of the page that came back, or `undefined` when it did not within `timeoutMs`.
152
+ */
153
+ expectPageBack(page, timeoutMs, signal) {
154
+ const { windowId, tab } = this.whereIs(page);
155
+ return new Promise((resolve) => {
156
+ const done = (value) => {
157
+ clearTimeout(timer);
158
+ this.attachWaiters.delete(waiter);
159
+ signal?.removeEventListener("abort", stop);
160
+ resolve(value);
161
+ };
162
+ const stop = () => {
163
+ done(void 0);
164
+ };
165
+ signal?.addEventListener("abort", stop, { once: true });
166
+ const waiter = (attached) => {
167
+ const connection = this.connections.get(attached);
168
+ if (attached === windowId || tab !== void 0 && connection?.tab === tab) done(attached);
169
+ };
170
+ const timer = setTimeout(() => {
171
+ done(void 0);
172
+ }, timeoutMs);
173
+ this.attachWaiters.add(waiter);
174
+ });
175
+ }
176
+ tellPage(windowId, running) {
177
+ const connection = this.connections.get(windowId);
178
+ if (connection !== void 0) this.tell(connection, running);
179
+ }
180
+ tell(connection, running) {
181
+ try {
182
+ connection.stream.notify?.("run", running);
183
+ } catch {}
184
+ }
107
185
  /** Drive this page from now on (it is the one the Host just sent to a new place), until the person focuses another. */
108
186
  prefer(windowId) {
109
187
  const connection = this.connections.get(windowId);
110
188
  if (connection === void 0) return;
111
189
  this.preferred = windowId;
112
190
  connection.lastActive = ++this.clock;
191
+ for (const asker of this.askers) if (!this.connections.has(asker.windowId) && (asker.tab === void 0 || asker.tab === connection.tab)) asker.windowId = windowId;
192
+ if (this.askers.some((asker) => asker.windowId === windowId)) this.tellPage(windowId, "1");
113
193
  }
114
194
  /**
115
195
  * Ask the current page to do something. A read-only call (`look`, `wait`) that the page does not acknowledge in time goes to the next page, and the silent one is last in line
@@ -119,6 +199,8 @@ var UiChannel = class {
119
199
  async call(request, options = {}, retried = false) {
120
200
  if (this.closed) throw new UiControlError("unloaded", "the door was unloaded");
121
201
  const readOnlyCall = request.op === "snapshot" || request.op === "wait";
202
+ if (options.page !== void 0) return await this.callPage(request, options.page, options, readOnlyCall);
203
+ if (this.requirePage) throw new UiControlError("no-window", "this door drives only the page whose visitor asked, and the call names no page");
122
204
  if (readOnlyCall && this.attachWaitMs > 0 && this.connections.size === 0) await this.expectNewPage(this.attachWaitMs);
123
205
  const ordered = this.ordered();
124
206
  const candidates = options.window === void 0 ? ordered : ordered.filter((connection) => connection.shellWindow === options.window);
@@ -144,6 +226,25 @@ var UiChannel = class {
144
226
  }
145
227
  throw new UiControlError("timeout", `no page of the app answered within ${String(Math.round(this.ackMs / 1e3))} 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`);
146
228
  }
229
+ /**
230
+ * 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
231
+ * documents (it reloads, or moves to another path) is waited for, up to the attach wait.
232
+ */
233
+ async callPage(request, page, options, readOnly) {
234
+ let target = this.connections.get(this.whereIs(page).windowId);
235
+ if (target === void 0 && this.attachWaitMs > 0) {
236
+ const back = await this.expectPageBack(page, this.attachWaitMs);
237
+ if (back !== void 0) target = this.connections.get(back);
238
+ }
239
+ if (target === void 0) throw new UiControlError("no-window", "the page this run is for is not open any more");
240
+ if (options.signal?.aborted === true) throw new UiControlError("aborted", "the call was cancelled");
241
+ try {
242
+ return await this.callOne(target, request, options, readOnly);
243
+ } catch (cause) {
244
+ 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");
245
+ throw cause;
246
+ }
247
+ }
147
248
  callOne(target, request, options, failOver) {
148
249
  const signal = options.signal;
149
250
  const id = this.nextId;
@@ -209,16 +310,22 @@ var UiChannel = class {
209
310
  }
210
311
  /**
211
312
  * 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).
313
+ * @param signal - stop waiting; the promise then answers `undefined`.
212
314
  * @returns that id, or `undefined` when no new page connected within `timeoutMs`.
213
315
  */
214
- expectNewPage(timeoutMs) {
316
+ expectNewPage(timeoutMs, signal) {
215
317
  const known = new Set(this.connections.keys());
216
318
  return new Promise((resolve) => {
217
319
  const done = (value) => {
218
320
  clearTimeout(timer);
219
321
  this.attachWaiters.delete(waiter);
322
+ signal?.removeEventListener("abort", stop);
220
323
  resolve(value);
221
324
  };
325
+ const stop = () => {
326
+ done(void 0);
327
+ };
328
+ signal?.addEventListener("abort", stop, { once: true });
222
329
  const waiter = (windowId) => {
223
330
  if (!known.has(windowId)) done(windowId);
224
331
  };
@@ -258,9 +365,10 @@ var UiChannel = class {
258
365
  for (const connection of [...this.connections.values()]) connection.stream.close();
259
366
  this.connections.clear();
260
367
  }
261
- /** The order pages are driven in: the one the Host sent somewhere, suspect ones last, the one in front, then the most recently active. */
368
+ /** 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. */
262
369
  ordered() {
263
- const rank = (connection) => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0);
370
+ const asker = [...this.askers].reverse().find((entry) => this.connections.has(entry.windowId))?.windowId;
371
+ const rank = (connection) => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0) + (connection.windowId === asker && !connection.suspect ? 8 : 0);
264
372
  return [...this.connections.values()].sort((a, b) => rank(b) - rank(a) || b.lastActive - a.lastActive);
265
373
  }
266
374
  /** Forget a connection; its calls in flight settle as `disconnected` at once rather than waiting for their timeout. */
@@ -423,6 +531,8 @@ function pageChannelRoutes(channel, options) {
423
531
  const focused = c.req.query("focused") === "1";
424
532
  const shellQuery = Number(c.req.query("shell"));
425
533
  const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : void 0;
534
+ const tabQuery = c.req.query("tab");
535
+ const tab = tabQuery !== void 0 && WINDOW_ID.test(tabQuery) ? tabQuery : void 0;
426
536
  const signal = c.req.raw.signal;
427
537
  return streamSSE(c, async (stream) => {
428
538
  let end = () => {};
@@ -441,8 +551,9 @@ function pageChannelRoutes(channel, options) {
441
551
  send: (data) => {
442
552
  write("call", data);
443
553
  },
554
+ notify: write,
444
555
  close: end
445
- }, shellWindow);
556
+ }, shellWindow, tab);
446
557
  const beat = setInterval(() => {
447
558
  write("ping", "");
448
559
  }, heartbeatMs);
@@ -476,7 +587,8 @@ function renderSnapshot(snapshot, more = (cursor) => `call ui_snapshot with curs
476
587
  const level = node.level === void 0 ? "" : ` level=${String(node.level)}`;
477
588
  const states = node.states.length === 0 ? "" : ` [${node.states.join(", ")}]`;
478
589
  const value = node.value === void 0 ? "" : ` value=${JSON.stringify(node.value)}`;
479
- return `${" ".repeat(Math.min(node.depth, 8))}- ${node.role}${node.name === "" ? "" : ` ${JSON.stringify(node.name)}`}${level}${states}${value} [ref=${node.ref}]`;
590
+ const options = node.options === void 0 ? "" : ` options=${JSON.stringify(node.options)}`;
591
+ return `${" ".repeat(Math.min(node.depth, 8))}- ${node.role}${node.name === "" ? "" : ` ${JSON.stringify(node.name)}`}${level}${states}${value}${options} [ref=${node.ref}]`;
480
592
  });
481
593
  const rest = snapshot.nextCursor === void 0 ? "" : `\n(${String(snapshot.total - snapshot.nextCursor)} more elements; ${more(snapshot.nextCursor)})`;
482
594
  return `Page: ${snapshot.title || "(untitled)"}\nSnapshot ${String(snapshot.generation)}, ${String(snapshot.nodes.length)} of ${String(snapshot.total)} elements:\n${lines.join("\n")}${rest}`;