acrylblends 1.1.0 → 1.3.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 (263) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +47 -2
  3. package/dist/index.js +2017 -32
  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 +5 -3
  8. package/docs/framework/workshop/README.md +139 -153
  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 +87 -9
  72. package/lib/cordis/door.js +324 -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/plugin.json +3 -0
  98. package/plugins/acryl-door/src/cli/acryl.ts +63 -6
  99. package/plugins/acryl-door/src/host/channel.ts +86 -9
  100. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  101. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  102. package/plugins/acryl-door/src/host/door.ts +128 -24
  103. package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
  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 +14 -1
  107. package/plugins/acryl-door/src/index.ts +168 -30
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +3 -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 +32 -4
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +41 -2
  115. package/plugins/acryl-door/src/page/outline.ts +56 -0
  116. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  117. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  118. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  119. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  120. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  121. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  122. package/plugins/acryl-node-server/src/index.ts +304 -16
  123. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  124. package/plugins/acryl-page/plugin.json +12 -0
  125. package/plugins/acryl-page/src/index.ts +163 -0
  126. package/plugins/acryl-router/src/service.ts +77 -14
  127. package/plugins/acryl-router-http/src/index.ts +15 -1
  128. package/plugins/acryl-router-http/src/serve.ts +31 -0
  129. package/plugins/acryl-web-server/plugin.json +14 -0
  130. package/plugins/acryl-web-server/src/index.ts +164 -0
  131. package/plugins/agent/agent.ts +38 -7
  132. package/plugins/agent/index.ts +1 -1
  133. package/plugins/agent/model.ts +10 -2
  134. package/plugins/agent/tools.ts +9 -3
  135. package/plugins/agent-panel/client/describe.ts +57 -0
  136. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  137. package/plugins/agent-panel/client/frame.ts +51 -0
  138. package/plugins/agent-panel/client/main.ts +238 -26
  139. package/plugins/agent-panel/drivers.ts +7 -2
  140. package/plugins/agent-panel/headless.ts +5 -2
  141. package/plugins/agent-panel/index.ts +146 -57
  142. package/plugins/agent-panel/mount.ts +27 -0
  143. package/plugins/agent-panel/visitors.ts +231 -0
  144. package/plugins/builder/index.ts +13 -35
  145. package/plugins/builder/package.ts +186 -0
  146. package/plugins/builder/plugin.json +3 -1
  147. package/plugins/desktop/frame.ts +231 -0
  148. package/plugins/desktop/launch.ts +44 -9
  149. package/plugins/desktop/main.ts +100 -32
  150. package/plugins/desktop/window.ts +21 -4
  151. package/plugins/driver-pty/client/terminal.ts +4 -1
  152. package/plugins/driver-pty/index.ts +38 -11
  153. package/plugins/driver-pty/spawn.ts +38 -0
  154. package/plugins/driver-pty/terminal.ts +2 -0
  155. package/plugins/extensions/index.ts +47 -18
  156. package/plugins/extensions/plugin-store.ts +27 -0
  157. package/plugins/extensions/plugin.json +1 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/extensions/stage.ts +15 -5
  160. package/plugins/feedback/client.d.ts +22 -0
  161. package/plugins/feedback/client.js +471 -0
  162. package/plugins/feedback/index.ts +143 -0
  163. package/plugins/feedback/plugin.json +11 -0
  164. package/plugins/feedback/store.ts +307 -0
  165. package/plugins/home/index.ts +122 -0
  166. package/plugins/home/plugin.json +11 -0
  167. package/plugins/instance/index.ts +6 -0
  168. package/plugins/manifest/app-plugins.ts +86 -0
  169. package/plugins/manifest/index.ts +12 -2
  170. package/plugins/manifest/plugin.json +1 -1
  171. package/plugins/manifest/snapshot.ts +275 -0
  172. package/plugins/openai/index.ts +64 -5
  173. package/plugins/openai/prompt.ts +23 -0
  174. package/plugins/plugin-share/README.md +51 -0
  175. package/plugins/plugin-share/index.ts +467 -0
  176. package/plugins/plugin-share/plugin.json +15 -0
  177. package/plugins/plugin-share/publish.ts +65 -0
  178. package/plugins/plugin-share/pull-check.ts +129 -0
  179. package/plugins/plugin-share/pulled-records.ts +70 -0
  180. package/shared/AGENTS.core.md +36 -16
  181. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  182. package/shared/skills/acryl-app/SKILL.md +13 -0
  183. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  184. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  185. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  186. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  187. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  188. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  189. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  190. package/templates/agent/.gitattributes +2 -0
  191. package/templates/agent/AGENTS.md +1 -1
  192. package/templates/agent/blend.yaml +6 -0
  193. package/templates/agent/gitignore +1 -0
  194. package/templates/agent/package.json +1 -0
  195. package/templates/agent/src/app.ts +154 -33
  196. package/templates/agent/src/frame.ts +17 -0
  197. package/templates/agent/src/main.ts +5 -3
  198. package/templates/agent/src/routes.ts +38 -19
  199. package/templates/agent/template.json +3 -0
  200. package/templates/agent/test/app.test.ts +45 -3
  201. package/templates/blank/.gitattributes +2 -0
  202. package/templates/blank/AGENTS.md +3 -2
  203. package/templates/blank/blend.yaml +11 -0
  204. package/templates/blank/gitignore +1 -0
  205. package/templates/blank/package.json +1 -0
  206. package/templates/blank/src/app.ts +100 -29
  207. package/templates/blank/src/door.ts +7 -0
  208. package/templates/blank/src/first-page.ts +5 -0
  209. package/templates/blank/src/frame.ts +17 -0
  210. package/templates/blank/src/routes.ts +65 -8
  211. package/templates/blank/template.json +14 -1
  212. package/templates/blank/test/app.test.ts +103 -16
  213. package/templates/chat/.gitattributes +2 -0
  214. package/templates/chat/blend.yaml +2 -1
  215. package/templates/chat/gitignore +1 -0
  216. package/templates/chat/package.json +1 -0
  217. package/templates/chat/src/app.ts +72 -22
  218. package/templates/chat/src/chat-app.ts +11 -2
  219. package/templates/chat/src/client/chat.tsx +4 -2
  220. package/templates/chat/src/frame.ts +17 -0
  221. package/templates/chat/src/page.tsx +6 -4
  222. package/templates/chat/src/routes.ts +14 -4
  223. package/templates/chat/src/runtime.ts +9 -3
  224. package/templates/chat/template.json +3 -1
  225. package/templates/chat/test/chat.test.ts +46 -1
  226. package/templates/chat/test/server.test.ts +17 -3
  227. package/templates/nodejs/.gitattributes +2 -0
  228. package/templates/nodejs/blend.yaml +2 -1
  229. package/templates/nodejs/gitignore +1 -0
  230. package/templates/nodejs/package.json +1 -0
  231. package/templates/nodejs/src/app.ts +76 -21
  232. package/templates/nodejs/src/frame.ts +17 -0
  233. package/templates/nodejs/src/page.tsx +3 -3
  234. package/templates/nodejs/src/routes.ts +32 -13
  235. package/templates/nodejs/template.json +3 -1
  236. package/templates/nodejs/test/app.test.ts +38 -3
  237. package/templates/nodejs/test/server.test.ts +17 -3
  238. package/templates/notes/.gitattributes +2 -0
  239. package/templates/notes/blend.yaml +2 -1
  240. package/templates/notes/gitignore +1 -0
  241. package/templates/notes/package.json +1 -0
  242. package/templates/notes/src/app.ts +72 -22
  243. package/templates/notes/src/frame.ts +17 -0
  244. package/templates/notes/src/notes-app.ts +24 -17
  245. package/templates/notes/src/routes.ts +11 -4
  246. package/templates/notes/src/runtime.ts +9 -3
  247. package/templates/notes/src/views.tsx +26 -17
  248. package/templates/notes/template.json +3 -1
  249. package/templates/notes/test/notes.test.ts +39 -1
  250. package/templates/notes/test/server.test.ts +17 -3
  251. package/templates/todo/.gitattributes +2 -0
  252. package/templates/todo/blend.yaml +3 -0
  253. package/templates/todo/gitignore +1 -0
  254. package/templates/todo/package.json +1 -0
  255. package/templates/todo/src/app.ts +81 -27
  256. package/templates/todo/src/door.ts +7 -0
  257. package/templates/todo/src/frame.ts +17 -0
  258. package/templates/todo/src/page.ts +13 -6
  259. package/templates/todo/src/todo.ts +18 -6
  260. package/templates/todo/template.json +11 -1
  261. package/templates/todo/test/todo.test.ts +67 -9
  262. package/lib/src-LX0E4ec4.js +0 -117
  263. package/shared/SKILL.md +0 -14
@@ -5,6 +5,8 @@ import { t as TrieRouter } from "./trie-router-JydLKUa2.js";
5
5
  import { t as Acryl } from "./app-CQcGWjrk.js";
6
6
  import "./core-BOu1jYua.js";
7
7
  import "./connection-C7xgP1hP.js";
8
+ import { appendFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
8
10
  import { Service } from "@deepseek-ai/cordis";
9
11
  import z from "@deepseek-ai/schemastery";
10
12
  //#region ../../plugins/acryl-router/src/service.ts
@@ -17,6 +19,28 @@ import z from "@deepseek-ai/schemastery";
17
19
  * response body still streaming and waits for all of it to settle.
18
20
  */
19
21
  const DEFAULT_DISPOSE_GRACE_MS = 2e3;
22
+ /** How long disposal waits for the flights it had to abort after the drain, before it continues without them. */
23
+ const ABORT_SETTLE_MS = 300;
24
+ /** Set by ACRYL_ROUTER_TRACE=1: append request-signal lifecycle lines to ACRYL_ROUTER_TRACE_FILE (the CI artifact) - diagnostics for the abort-reaches-handler defect. */
25
+ const traceFile = () => process.env.ACRYL_ROUTER_TRACE_FILE ?? `${tmpdir()}/acryl-router-trace.log`;
26
+ const trace = (line) => {
27
+ if (process.env.ACRYL_ROUTER_TRACE !== "1") return;
28
+ try {
29
+ appendFileSync(traceFile(), `${String(Date.now())} [${String(process.pid)}] ${line}\n`);
30
+ } catch {}
31
+ };
32
+ let flightSeq = 0;
33
+ const SIGNAL_TAG = Symbol.for("acryl.router.signalTag");
34
+ const tagOf = (signal) => {
35
+ if (signal === void 0) return "none";
36
+ const tag = signal[SIGNAL_TAG];
37
+ return typeof tag === "string" ? tag : "untagged";
38
+ };
39
+ const tagSignal = (signal, tag) => {
40
+ try {
41
+ signal[SIGNAL_TAG] = tag;
42
+ } catch {}
43
+ };
20
44
  /** Every field has a default, so a caller passes only what it changes: `ctx.plugin(plugin, { ... })` takes the partial shape. */
21
45
  const Config = z.object({
22
46
  trailingSlash: z.union(["strict", "ignore"]).default("strict").description("Whether `/a` and `/a/` are different paths."),
@@ -64,8 +88,8 @@ var Router = class extends Service {
64
88
  };
65
89
  ctx.effect(() => async () => {
66
90
  this.state.closing = true;
67
- await this.end([...this.flights], "disposal");
68
- }, "acryl-router: abort and settle in-flight requests");
91
+ await this.drain();
92
+ }, "acryl-router: drain in-flight requests, then abort what is left");
69
93
  }
70
94
  /** Registrations currently held (mounts, and the router-wide error and not-found handlers); a leak check for reload tests. */
71
95
  get size() {
@@ -83,7 +107,7 @@ var Router = class extends Service {
83
107
  const remove = () => {
84
108
  if (!this.state.mounts.delete(mount)) return Promise.resolve();
85
109
  this.state.composed = void 0;
86
- return this.end([...this.flights].filter((flight) => (flight.running.get(mount) ?? 0) > 0 || flight.producer === mount), `removing ${path}`);
110
+ return this.drainFlights([...this.flights].filter((flight) => (flight.running.get(mount) ?? 0) > 0 || flight.producer === mount), `removing ${path}`);
87
111
  };
88
112
  const served = new Set(app.routes.map((route) => `${route.method} ${mergePath(path, route.path)}`));
89
113
  const owner = this.ctx;
@@ -125,6 +149,7 @@ var Router = class extends Service {
125
149
  const handler = async (c, next) => {
126
150
  const flight = this.flightOf.get(c.req.raw);
127
151
  if (flight === void 0) return inner(c, next);
152
+ trace(`handler ${flight.tag} requestTag=${tagOf(c.req.raw.signal)} innerTag=${tagOf(flight.inner.signal)} sameSignal=${String(c.req.raw.signal === flight.inner.signal)} aborted=${String(c.req.raw.signal.aborted)}`);
128
153
  let calledNext = false;
129
154
  flight.running.set(mount, (flight.running.get(mount) ?? 0) + 1);
130
155
  try {
@@ -145,22 +170,52 @@ var Router = class extends Service {
145
170
  }
146
171
  return mount;
147
172
  }
148
- /** Abort `flights`, cut their bodies short and wait for them to settle, at most `disposeGraceMs`. */
149
- async end(flights, why) {
173
+ /**
174
+ * 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
175
+ * 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
176
+ * its abort signal.
177
+ */
178
+ async drain() {
179
+ await this.drainFlights([...this.flights], "disposal");
180
+ }
181
+ /** The one draining rule: let `flights` finish inside the grace, then abort and settle what is left. */
182
+ async drainFlights(flights, why) {
183
+ trace(`drain ${why} chosen=[${flights.map((flight) => flight.tag).join(", ")}]`);
150
184
  if (flights.length === 0) return;
151
- for (const flight of flights) {
152
- flight.controller.abort();
153
- flight.endBody();
185
+ if (await this.settledWithin(flights, this.graceMs)) {
186
+ trace(`drain ${why} settled inside the grace`);
187
+ return;
154
188
  }
189
+ const left = flights.filter((flight) => this.flights.has(flight));
190
+ trace(`drain ${why} grace over left=[${left.map((flight) => flight.tag).join(", ")}]`);
191
+ if (left.length > 0) await this.end(left, why, ABORT_SETTLE_MS);
192
+ }
193
+ /** Resolve true when every flight settled inside `ms`, false at the deadline. */
194
+ async settledWithin(flights, ms) {
155
195
  let timer;
156
196
  const gaveUp = new Promise((resolve) => {
157
197
  timer = setTimeout(() => {
158
- resolve("gave-up");
159
- }, this.graceMs);
198
+ resolve(false);
199
+ }, ms);
160
200
  });
161
- const outcome = await Promise.race([Promise.allSettled(flights.map((flight) => flight.settled)).then(() => "settled"), gaveUp]);
201
+ const outcome = await Promise.race([Promise.allSettled(flights.map((flight) => flight.settled)).then(() => true), gaveUp]);
162
202
  clearTimeout(timer);
163
- if (outcome === "gave-up") this.ctx.logger.warn(`acryl-router: ${String(flights.length)} request(s) did not finish ${String(this.graceMs)} ms after abort (${why}); continued without them`);
203
+ return outcome;
204
+ }
205
+ /** Abort `flights`, cut their bodies short and wait for them to settle, at most `settleMs`. */
206
+ async end(flights, why, settleMs = this.graceMs) {
207
+ if (flights.length === 0) return;
208
+ for (const flight of flights) {
209
+ trace(`abort ${flight.tag} before=${String(flight.controller.signal.aborted)}`);
210
+ flight.controller.abort();
211
+ trace(`abort ${flight.tag} after=${String(flight.controller.signal.aborted)} innerAborted=${String(flight.inner.signal.aborted)}`);
212
+ queueMicrotask(() => {
213
+ trace(`post-abort ${flight.tag} innerAborted=${String(flight.inner.signal.aborted)}`);
214
+ });
215
+ flight.endBody();
216
+ }
217
+ if (await this.settledWithin(flights, settleMs)) return;
218
+ this.ctx.logger.warn(`acryl-router: ${String(flights.length)} request(s) did not finish ${String(settleMs)} ms after abort (${why}); continued without them`);
164
219
  }
165
220
  /** Turn errors no mounted app handled into responses. One handler per router; the calling plugin owns it. */
166
221
  onError(handler) {
@@ -203,7 +258,10 @@ var Router = class extends Service {
203
258
  * `bindings` is an object an adapter hands to the handler as `c.env` (an upgrade request's socket hooks, for `upgradeWebSocket`); `connection` is added to it.
204
259
  */
205
260
  fetch(request, connection = {}, bindings) {
206
- if (this.state.closing) return Promise.resolve(new Response("Service Unavailable", { status: 503 }));
261
+ if (this.state.closing) return Promise.resolve(new Response("Service Unavailable", {
262
+ status: 503,
263
+ headers: { connection: "close" }
264
+ }));
207
265
  const controller = new AbortController();
208
266
  const forward = () => controller.abort();
209
267
  if (request.signal.aborted) controller.abort();
@@ -220,8 +278,13 @@ var Router = class extends Service {
220
278
  let bodyDone = Promise.resolve();
221
279
  let endBody = () => {};
222
280
  let resolveSettled;
281
+ const tag = `f${String(++flightSeq)}`;
282
+ const inner = new Request(request, { signal: controller.signal });
283
+ tagSignal(inner.signal, tag);
223
284
  const flight = {
224
285
  controller,
286
+ inner,
287
+ tag,
225
288
  running: /* @__PURE__ */ new Map(),
226
289
  producer: void 0,
227
290
  endBody: () => {
@@ -231,7 +294,7 @@ var Router = class extends Service {
231
294
  resolveSettled = resolve;
232
295
  })
233
296
  };
234
- const inner = new Request(request, { signal: controller.signal });
297
+ trace(`create ${tag} ${request.method} ${new URL(request.url).pathname}`);
235
298
  this.flightOf.set(inner, flight);
236
299
  const response = this.dispatch(inner, env, executionCtx).then((raw) => {
237
300
  const tracked = this.track(raw);
@@ -744,6 +744,12 @@ Object.defineProperty(Response$1, "json", {
744
744
  configurable: true
745
745
  });
746
746
  //#endregion
747
+ //#region ../../plugins/acryl-node-server/src/adapter/raw.ts
748
+ /** Returned by a raw-aware fetch callback: the node response is already owned, do not write. */
749
+ const RAW_RESPONSE_HANDLED = Symbol.for("acryl-node-server.raw-response");
750
+ /** Set on an upgrade request a raw handler took over; the adapter's WebSocket setup skips it. */
751
+ const RAW_UPGRADE_HANDLED = Symbol.for("acryl-node-server.raw-upgrade");
752
+ //#endregion
747
753
  //#region ../../plugins/acryl-node-server/src/adapter/utils.ts
748
754
  async function readWithoutBlocking(readPromise) {
749
755
  return Promise.race([readPromise, Promise.resolve().then(() => Promise.resolve(void 0))]);
@@ -1040,6 +1046,7 @@ const getRequestListener = (fetchCallback, options = {}) => {
1040
1046
  incoming,
1041
1047
  outgoing
1042
1048
  });
1049
+ if (res === RAW_RESPONSE_HANDLED) return;
1043
1050
  if (!isPromise(res) && isImmediateCacheableResponse(res)) {
1044
1051
  if (needsBodyCleanup && !incoming.readableEnded) outgoing.once("finish", () => {
1045
1052
  if (!incoming.readableEnded) drainIncoming(incoming);
@@ -1137,6 +1144,7 @@ const appendResponseHeaders = (headers, responseHeaders) => {
1137
1144
  });
1138
1145
  };
1139
1146
  const rejectUpgradeRequest = (socket, status, responseHeaders) => {
1147
+ if (socket.destroyed || socket.writableEnded) return;
1140
1148
  const responseLines = ["Connection: close", "Content-Length: 0"];
1141
1149
  appendResponseHeaders(responseLines, responseHeaders);
1142
1150
  socket.end(`HTTP/1.1 ${status.toString()} ${STATUS_CODES[status] ?? ""}\r\n${responseLines.join("\r\n")}\r\n\r
@@ -1154,7 +1162,7 @@ const createUpgradeRequest = (request) => {
1154
1162
  return new Request(url, { headers });
1155
1163
  };
1156
1164
  const setupWebSocket = (options) => {
1157
- const { server, fetchCallback, wss } = options;
1165
+ const { server, fetchCallback, wss, skipWhenRawUpgrade = false } = options;
1158
1166
  const waiterMap = /* @__PURE__ */ new Map();
1159
1167
  wss.on("connection", (ws, request) => {
1160
1168
  const waiter = waiterMap.get(request);
@@ -1180,8 +1188,9 @@ const setupWebSocket = (options) => {
1180
1188
  });
1181
1189
  };
1182
1190
  server.on("upgrade", async (request, socket, head) => {
1191
+ if (skipWhenRawUpgrade && request[RAW_UPGRADE_HANDLED] === true) return;
1183
1192
  if (request.headers.upgrade?.toLowerCase() !== "websocket") {
1184
- if (server.listenerCount("upgrade") === 1) rejectUpgradeRequest(socket, 400);
1193
+ rejectUpgradeRequest(socket, 400);
1185
1194
  return;
1186
1195
  }
1187
1196
  const env = {
@@ -1199,13 +1208,13 @@ const setupWebSocket = (options) => {
1199
1208
  responseHeaders = response.headers;
1200
1209
  }
1201
1210
  } catch {
1202
- if (server.listenerCount("upgrade") === 1) rejectUpgradeRequest(socket, 500);
1211
+ rejectUpgradeRequest(socket, 500);
1203
1212
  return;
1204
1213
  }
1205
1214
  const waiter = waiterMap.get(request);
1206
1215
  if (!waiter || waiter.connectionSymbol !== env[CONNECTION_SYMBOL_KEY]) {
1207
1216
  rejectWaiter(request);
1208
- if (server.listenerCount("upgrade") === 1) rejectUpgradeRequest(socket, status, responseHeaders);
1217
+ rejectUpgradeRequest(socket, status, responseHeaders);
1209
1218
  return;
1210
1219
  }
1211
1220
  const addResponseHeaders = (headers) => {
@@ -1389,7 +1398,7 @@ var UpgradeHub = class {
1389
1398
  * Accept upgrades from `source` and answer them with `router`: the request runs through the router's middleware with the socket's peer address and local port as connection
1390
1399
  * facts (so the same loopback, origin and host checks work), and the upgrade bindings travel in `c.env` for `upgradeWebSocket`.
1391
1400
  */
1392
- attach(source, router) {
1401
+ attach(source, router, options = {}) {
1393
1402
  const fetchCallback = (request, env) => {
1394
1403
  const { incoming } = env;
1395
1404
  return router.fetch(request, {
@@ -1400,7 +1409,8 @@ var UpgradeHub = class {
1400
1409
  setupWebSocket({
1401
1410
  server: source,
1402
1411
  fetchCallback,
1403
- wss: this.server
1412
+ wss: this.server,
1413
+ ...options
1404
1414
  });
1405
1415
  }
1406
1416
  /** How many sockets are open. */
@@ -1541,4 +1551,4 @@ async function testWebSocket(fetch, path, options = {}) {
1541
1551
  };
1542
1552
  }
1543
1553
  //#endregion
1544
- export { serve as a, Request$1 as c, createAdaptorServer as i, RequestError as l, UpgradeEmitter as n, upgradeWebSocket as o, UpgradeHub as r, getRequestListener as s, testWebSocket as t };
1554
+ export { serve as a, RAW_RESPONSE_HANDLED as c, RequestError as d, createAdaptorServer as i, RAW_UPGRADE_HANDLED as l, UpgradeEmitter as n, upgradeWebSocket as o, UpgradeHub as r, getRequestListener as s, testWebSocket as t, Request$1 as u };
@@ -88,7 +88,9 @@ declare class UpgradeHub {
88
88
  * Accept upgrades from `source` and answer them with `router`: the request runs through the router's middleware with the socket's peer address and local port as connection
89
89
  * facts (so the same loopback, origin and host checks work), and the upgrade bindings travel in `c.env` for `upgradeWebSocket`.
90
90
  */
91
- attach(source: UpgradeSource, router: UpgradeRouter): void;
91
+ attach(source: UpgradeSource, router: UpgradeRouter, options?: {
92
+ skipWhenRawUpgrade?: boolean;
93
+ }): void;
92
94
  /** How many sockets are open. */
93
95
  get size(): number;
94
96
  /** `upgradeWebSocket` for the plugin `owner`: the sockets it accepts are closed with 1001 when that plugin unloads. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -405,6 +405,7 @@
405
405
  "dist",
406
406
  "lib",
407
407
  "templates",
408
+ "legacy",
408
409
  "shared",
409
410
  "plugins",
410
411
  "docs"
@@ -11,6 +11,9 @@
11
11
  "devDependencies": {
12
12
  "ws": "8.21.3"
13
13
  },
14
+ "scripts": {
15
+ "acryl": "node plugins/acryl-door/src/cli/acryl.ts"
16
+ },
14
17
  "mount": "mount it after acryl-node-server and extensions in development builds: root.plugin(door); add <script type=\"module\" src=\"/api/acryl-door/page.js\"></script> to the pages; the command is .acryl/bin/acryl",
15
18
  "gitignore": [".acryl/"],
16
19
  "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * The door's operator client: what `<app>/.acryl/bin/acryl` runs. It finds the app from the folder it is started in (the nearest
3
+ * The door's operator client: what `npm run acryl -- <verb>` and `<app>/.acryl/bin/acryl` run. It finds the app from the folder it is started in (the nearest
4
4
  * `.acryl/door.json` upward, or `--dir`), never through PATH or a machine-wide registry, so it reaches only the app running from that
5
5
  * folder. The secret is read from that file and sent as a `Bearer` header; it is never printed.
6
6
  *
@@ -10,13 +10,14 @@
10
10
  * Exit codes: 0 done, 1 the verb failed (its code and message are printed), 2 a usage error, 3 no running app in this folder.
11
11
  */
12
12
 
13
+ import { createInterface } from 'node:readline/promises'
13
14
  import { existsSync, realpathSync } from 'node:fs'
14
15
  import { dirname, join, resolve } from 'node:path'
15
16
  import { fileURLToPath } from 'node:url'
16
17
  import { readDoorFile, STATE_DIR, DOOR_FILE } from '../host/door-file.ts'
17
18
 
18
19
  const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'])
19
- const VALUED = new Set(['--dir', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path'])
20
+ const VALUED = new Set(['--dir', '--owner', '--license', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path'])
20
21
  const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--force', '--help'])
21
22
 
22
23
  export class UsageError extends Error {}
@@ -42,7 +43,7 @@ const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
42
43
  act navigate --path <path of the app, e.g. /demo>
43
44
  act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
44
45
  (look and every act take --window <n>: the desktop window whose page it is for; window list prints the numbers)
45
- plugin list | plugin add <folder> | plugin remove <name> | plugin reload
46
+ plugin list | plugin add <folder> | plugin pull <source> | plugin publish <name> [--owner <account>] [--license <id>] | plugin remove <name> | plugin reload
46
47
  window list | window open [--path <p>] | window focus --id <n> | window close --id <n> [--force]
47
48
  window resize --id <n> --width <px> --height <px>
48
49
  menu list | menu click --path "File>New Note" [--force]
@@ -138,8 +139,15 @@ export function parseInvocation(argv: readonly string[], env: NodeJS.ProcessEnv
138
139
  case 'plugin': {
139
140
  if (second === 'list' || second === 'reload') return make(`plugin-${second}`)
140
141
  if (second === 'add') { if (third === undefined) throw new UsageError('plugin add needs a folder, e.g. plugin add plugins/pomodoro'); return make('plugin-add', { folder: third }) }
142
+ if (second === 'pull') { if (third === undefined) throw new UsageError('plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0'); return make('plugin-pull', { source: third }) }
143
+ if (second === 'publish') {
144
+ if (third === undefined) throw new UsageError('plugin publish needs a plugin name, e.g. plugin publish pomodoro')
145
+ const owner = values.get('--owner')
146
+ const license = values.get('--license')
147
+ return make('plugin-publish', { name: third, ...(owner === undefined ? {} : { owner }), ...(license === undefined ? {} : { license }) })
148
+ }
141
149
  if (second === 'remove') { if (third === undefined) throw new UsageError('plugin remove needs a plugin name'); return make('plugin-remove', { name: third }) }
142
- throw new UsageError(`plugin needs list, add, remove or reload\n${USAGE}`)
150
+ throw new UsageError(`plugin needs list, add, pull, publish, remove or reload\n${USAGE}`)
143
151
  }
144
152
  case 'window': {
145
153
  const id = integer(values, '--id')
@@ -199,6 +207,11 @@ export interface Io {
199
207
  readonly out: (text: string) => void
200
208
  readonly err: (text: string) => void
201
209
  readonly fetch?: typeof fetch
210
+ /**
211
+ * Ask the person at this terminal; absent when nobody is there (a pipe, an agent's shell), and then nothing that needs a yes can happen.
212
+ * @returns whether they said yes.
213
+ */
214
+ readonly ask?: (question: string) => Promise<boolean>
202
215
  }
203
216
 
204
217
  /** Run one invocation. @returns the exit code. */
@@ -214,7 +227,7 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
214
227
  const app = findApp(io.cwd, invocation.dir)
215
228
  const file = app === undefined ? undefined : readDoorFile(app)
216
229
  if (app === undefined || file === undefined) {
217
- io.err(`no running app${app === undefined ? ` in ${io.cwd} or above it` : ` in ${app}`}: start it (npm run dev) in its folder, then try again\n`)
230
+ io.err(`no running app${app === undefined ? ` in ${io.cwd} or above it` : ` in ${app}`}: these commands reach the running app, so start it (npm run dev) in its folder in another terminal, then run the same line again\n`)
218
231
  return 3
219
232
  }
220
233
  const request = io.fetch ?? fetch
@@ -225,7 +238,7 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
225
238
  ? await request(`${file.url}/api/acryl-door/verbs`, { headers })
226
239
  : await request(`${file.url}/api/acryl-door/call`, { method: 'POST', headers, body: JSON.stringify({ verb: invocation.verb, args: invocation.args }) })
227
240
  } catch {
228
- io.err(`no running app in ${app}: ${file.url} does not answer (it stopped without removing .acryl/door.json?); start it again\n`)
241
+ io.err(`no running app in ${app}: ${file.url} does not answer (it stopped without removing .acryl/door.json?); start it (npm run dev), then run the same line again\n`)
229
242
  return 3
230
243
  }
231
244
  if (response.status === 403) {
@@ -243,9 +256,52 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
243
256
  return 1
244
257
  }
245
258
  io.out(invocation.json ? `${JSON.stringify(body.value)}\n` : `${body.text ?? ''}\n`)
259
+ if (invocation.verb === 'plugin-publish' && io.ask !== undefined && isPlan(body.value)) return confirmPublish(invocation, body.value, io, request, `${file.url}/api/acryl-door/publish-confirmed`, headers)
246
260
  return 0
247
261
  }
248
262
 
263
+ const isPlan = (value: unknown): value is PlanAsked =>
264
+ typeof value === 'object' && value !== null && 'owner' in value
265
+
266
+ /** What the prompt names. */
267
+ interface PlanAsked {
268
+ readonly owner: string
269
+ readonly repo: string
270
+ readonly version: string
271
+ readonly license: string
272
+ }
273
+
274
+ /** The person's yes: only what they just read is published, and anything but a plain yes sends nothing. */
275
+ async function confirmPublish(
276
+ invocation: Invocation,
277
+ plan: PlanAsked,
278
+ io: Io,
279
+ request: typeof fetch,
280
+ url: string,
281
+ headers: Record<string, string>,
282
+ ): Promise<number> {
283
+ const yes = await io.ask?.(`Publish ${plan.owner}/${plan.repo} v${plan.version} under ${plan.license}? Type yes to send it: `)
284
+ if (yes !== true) { io.out('Nothing was sent.\n'); return 0 }
285
+ const response = await request(url, { method: 'POST', headers, body: JSON.stringify({ name: invocation.args.name, owner: invocation.args.owner, license: invocation.args.license, confirm: plan }) })
286
+ const body = await response.json().catch(() => undefined) as { ok?: boolean; message?: string; value?: { ok?: boolean; releaseUrl?: string; pullLine?: string; reason?: string; next?: string } } | undefined
287
+ const result = body?.value
288
+ if (body?.ok !== true || result?.ok !== true) {
289
+ io.err(`Not published: ${result?.reason ?? body?.message ?? 'the app did not answer'}${result?.next === undefined ? '' : `. ${result.next}`}\n`)
290
+ return 1
291
+ }
292
+ io.out(`Published: ${result.releaseUrl ?? ''}\nA friend gets exactly these bytes with:\n ${result.pullLine ?? ''}\n`)
293
+ return 0
294
+ }
295
+
296
+ async function askAtTerminal(question: string): Promise<boolean> {
297
+ const prompt = createInterface({ input: process.stdin, output: process.stdout })
298
+ try {
299
+ return (await prompt.question(question)).trim().toLowerCase() === 'yes'
300
+ } finally {
301
+ prompt.close()
302
+ }
303
+ }
304
+
249
305
  /** True when this file is the program Node was started with (not imported by a test). */
250
306
  function isProgram(): boolean {
251
307
  const entry = process.argv[1]
@@ -262,6 +318,7 @@ if (isProgram()) {
262
318
  cwd: process.cwd(),
263
319
  out: (text) => { process.stdout.write(text) },
264
320
  err: (text) => { process.stderr.write(text) },
321
+ ...(process.stdin.isTTY && process.stdout.isTTY ? { ask: askAtTerminal } : {}),
265
322
  }).then((code) => { process.exitCode = code }, (cause: unknown) => {
266
323
  process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
267
324
  process.exitCode = 1
@@ -44,6 +44,8 @@ interface Connection {
44
44
  readonly windowId: string
45
45
  /** The desktop window the page is in, when the Electron shell tagged it. */
46
46
  readonly shellWindow: number | undefined
47
+ /** The browser tab the page is in (the page keeps it for the tab's lifetime): a page that reloads or moves to another path comes back with a new window id and the same tab. */
48
+ readonly tab: string | undefined
47
49
  focused: boolean
48
50
  /** Order of last focus (or connection), so the most recent wins. */
49
51
  lastActive: number
@@ -64,23 +66,39 @@ export interface ChannelOptions {
64
66
  * address registers again a moment later). 0 (the default) fails at once.
65
67
  */
66
68
  readonly attachWaitMs?: number
69
+ /**
70
+ * Every call must name the page it is for (`page`), and goes to that page alone: never to the page in front or the one used last, which may be another visitor's. A page-only door
71
+ * (a production assistant, one channel for every visitor of the app) sets it.
72
+ */
73
+ readonly requirePage?: boolean
74
+ }
75
+
76
+ /** A page whose panel asked for a run: the id the run knows it by, and where that page is now (the same tab after a reload or a move). */
77
+ interface Asker {
78
+ /** The window id the run was asked from: what its calls name as `page`. */
79
+ readonly origin: string
80
+ windowId: string
81
+ tab: string | undefined
67
82
  }
68
83
 
69
84
  /**
70
85
  * The pages and who drives which. One page answers a call, chosen the same way every time: the page whose panel asked for the run that is making the call (`driveFor`), else the page the
71
86
  * Host just sent somewhere (it comes back as a new page, `prefer`), else the one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
87
+ * A call that names its `page` (the page a run was asked from) goes to that page alone, followed into its tab when it reloads or moves, and to no other.
72
88
  */
73
89
  export class UiChannel {
74
90
  private readonly ackMs: number
75
91
  private readonly attachWaitMs: number
92
+ private readonly requirePage: boolean
76
93
  /** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
77
94
  private preferred: string | undefined
78
95
  /** The pages whose panels have a run going, the latest last: a call with no `window` goes to the latest of them that is still connected. */
79
- private readonly askers: Array<{ windowId: string }> = []
96
+ private readonly askers: Asker[] = []
80
97
 
81
98
  constructor(options: ChannelOptions = {}) {
82
99
  this.ackMs = options.ackMs ?? DEFAULT_ACK_MS
83
100
  this.attachWaitMs = options.attachWaitMs ?? 0
101
+ this.requirePage = options.requirePage === true
84
102
  }
85
103
 
86
104
  private readonly connections = new Map<string, Connection>()
@@ -95,12 +113,17 @@ export class UiChannel {
95
113
  * Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
96
114
  * @returns the function to call when this stream ends.
97
115
  */
98
- attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number): () => void {
116
+ attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number, tab?: string): () => void {
99
117
  const previous = this.connections.get(windowId)
100
118
  if (previous !== undefined) this.drop(previous, true)
101
119
  // A page that did not answer stays last in line when its stream reconnects: a tab that cannot answer reconnects just as readily, and would otherwise be first again.
102
- const connection: Connection = { stream, windowId, shellWindow, focused, lastActive: ++this.clock, suspect: previous?.suspect === true }
120
+ const connection: Connection = { stream, windowId, shellWindow, tab, focused, lastActive: ++this.clock, suspect: previous?.suspect === true }
103
121
  this.connections.set(windowId, connection)
122
+ // A run's page that reloaded or moved comes back as a new page in the same tab: the run goes on with it.
123
+ for (const asker of this.askers) {
124
+ if (asker.windowId === windowId) asker.tab ??= tab
125
+ else if (tab !== undefined && asker.tab === tab) asker.windowId = windowId
126
+ }
104
127
  // A page that reloaded or came back during its own run is told the run is still going.
105
128
  if (this.askers.some(asker => asker.windowId === windowId)) this.tell(connection, '1')
106
129
  for (const waiter of [...this.attachWaiters]) waiter(windowId)
@@ -149,7 +172,7 @@ export class UiChannel {
149
172
  * @returns the function to call when the run ends.
150
173
  */
151
174
  driveFor(windowId: string): () => void {
152
- const asker = { windowId }
175
+ const asker: Asker = { origin: windowId, windowId, tab: this.connections.get(windowId)?.tab }
153
176
  this.askers.push(asker)
154
177
  this.tellPage(windowId, '1')
155
178
  return () => {
@@ -161,6 +184,33 @@ export class UiChannel {
161
184
  }
162
185
  }
163
186
 
187
+ /** Where the page a run was asked from is now: the same window, or the page that replaced it in the same tab. */
188
+ private whereIs(page: string): { windowId: string; tab: string | undefined } {
189
+ const asker = [...this.askers].reverse().find(entry => entry.origin === page)
190
+ return asker === undefined ? { windowId: page, tab: this.connections.get(page)?.tab } : { windowId: asker.windowId, tab: asker.tab }
191
+ }
192
+
193
+ /**
194
+ * A promise for the run's page coming back after a reload or a move: the next page that connects in its tab (or with its window id). Ask for it BEFORE the call that makes the page
195
+ * leave. Pages of other tabs, another visitor's among them, are never taken for it.
196
+ * @param signal - stop waiting (the act did not move the page after all); the promise then answers `undefined`.
197
+ * @returns the window id of the page that came back, or `undefined` when it did not within `timeoutMs`.
198
+ */
199
+ expectPageBack(page: string, timeoutMs: number, signal?: AbortSignal): Promise<string | undefined> {
200
+ const { windowId, tab } = this.whereIs(page)
201
+ return new Promise((resolve) => {
202
+ const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); signal?.removeEventListener('abort', stop); resolve(value) }
203
+ const stop = (): void => { done(undefined) }
204
+ signal?.addEventListener('abort', stop, { once: true })
205
+ const waiter = (attached: string): void => {
206
+ const connection = this.connections.get(attached)
207
+ if (attached === windowId || (tab !== undefined && connection?.tab === tab)) done(attached)
208
+ }
209
+ const timer = setTimeout(() => { done(undefined) }, timeoutMs)
210
+ this.attachWaiters.add(waiter)
211
+ })
212
+ }
213
+
164
214
  private tellPage(windowId: string, running: '0' | '1'): void {
165
215
  const connection = this.connections.get(windowId)
166
216
  if (connection !== undefined) this.tell(connection, running)
@@ -176,8 +226,10 @@ export class UiChannel {
176
226
  if (connection === undefined) return
177
227
  this.preferred = windowId
178
228
  connection.lastActive = ++this.clock
179
- // A run's own page that reloaded or navigated comes back as a new page: the run goes on with that one.
180
- for (const asker of this.askers) if (!this.connections.has(asker.windowId)) asker.windowId = windowId
229
+ // A run's own page that reloaded or navigated comes back as a new page: the run goes on with that one. A run whose page has a tab follows only that tab, so
230
+ // another visitor's page is never taken for it.
231
+ for (const asker of this.askers)
232
+ if (!this.connections.has(asker.windowId) && (asker.tab === undefined || asker.tab === connection.tab)) asker.windowId = windowId
181
233
  if (this.askers.some(asker => asker.windowId === windowId)) this.tellPage(windowId, '1')
182
234
  }
183
235
 
@@ -186,9 +238,11 @@ export class UiChannel {
186
238
  * until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
187
239
  * @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
188
240
  */
189
- async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}, retried = false): Promise<UiResult> {
241
+ async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number; page?: string } = {}, retried = false): Promise<UiResult> {
190
242
  if (this.closed) throw new UiControlError('unloaded', 'the door was unloaded')
191
243
  const readOnlyCall = request.op === 'snapshot' || request.op === 'wait'
244
+ if (options.page !== undefined) return await this.callPage(request, options.page, options, readOnlyCall)
245
+ if (this.requirePage) throw new UiControlError('no-window', 'this door drives only the page whose visitor asked, and the call names no page')
192
246
  // A page that is moving to another address registers again a moment later: a read-only call right after a navigate or reload waits for it, rather than failing.
193
247
  if (readOnlyCall && this.attachWaitMs > 0 && this.connections.size === 0) await this.expectNewPage(this.attachWaitMs)
194
248
  const ordered = this.ordered()
@@ -218,6 +272,26 @@ export class UiChannel {
218
272
  throw new UiControlError('timeout', `no page of the app answered within ${String(Math.round(this.ackMs / 1000))} s: the app's pages are open but none responded. Likely causes: a tab in the background, a page that is still loading after a navigation (look again), a page busy running a long script, or a page that holds the browser's six connections with event streams of its own (a page should keep to two). Bring the app's tab to the front, or navigate it, then look again`)
219
273
  }
220
274
 
275
+ /**
276
+ * A call for one page (the page a run was asked from, followed into its tab): it goes to that page alone and never fails over to another one. A page that is between two
277
+ * documents (it reloads, or moves to another path) is waited for, up to the attach wait.
278
+ */
279
+ private async callPage(request: UiRequest, page: string, options: { signal?: AbortSignal; timeoutMs?: number }, readOnly: boolean): Promise<UiResult> {
280
+ let target = this.connections.get(this.whereIs(page).windowId)
281
+ if (target === undefined && this.attachWaitMs > 0) {
282
+ const back = await this.expectPageBack(page, this.attachWaitMs)
283
+ if (back !== undefined) target = this.connections.get(back)
284
+ }
285
+ if (target === undefined) throw new UiControlError('no-window', 'the page this run is for is not open any more')
286
+ if (options.signal?.aborted === true) throw new UiControlError('aborted', 'the call was cancelled')
287
+ try {
288
+ return await this.callOne(target, request, options, readOnly)
289
+ } catch (cause) {
290
+ if (cause instanceof Silent) throw new UiControlError('timeout', 'the page this run is for did not answer: is its tab in the background or still loading? Look again')
291
+ throw cause
292
+ }
293
+ }
294
+
221
295
  private callOne(target: Connection, request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number }, failOver: boolean): Promise<UiResult> {
222
296
  const signal = options.signal
223
297
  const id = this.nextId
@@ -260,12 +334,15 @@ export class UiChannel {
260
334
 
261
335
  /**
262
336
  * A promise for the window id of the next page that connects and was not connected when this was asked (the page that came back after a reload or a move).
337
+ * @param signal - stop waiting; the promise then answers `undefined`.
263
338
  * @returns that id, or `undefined` when no new page connected within `timeoutMs`.
264
339
  */
265
- expectNewPage(timeoutMs: number): Promise<string | undefined> {
340
+ expectNewPage(timeoutMs: number, signal?: AbortSignal): Promise<string | undefined> {
266
341
  const known = new Set(this.connections.keys())
267
342
  return new Promise((resolve) => {
268
- const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); resolve(value) }
343
+ const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); signal?.removeEventListener('abort', stop); resolve(value) }
344
+ const stop = (): void => { done(undefined) }
345
+ signal?.addEventListener('abort', stop, { once: true })
269
346
  const waiter = (windowId: string): void => { if (!known.has(windowId)) done(windowId) }
270
347
  const timer = setTimeout(() => { done(undefined) }, timeoutMs)
271
348
  this.attachWaiters.add(waiter)
@@ -6,10 +6,10 @@
6
6
 
7
7
  import { UiControlError } from '../page/protocol.ts'
8
8
  import { DoorError, type Door, type ToolsPort } from './door.ts'
9
- import { chatToolName, VERB_SPECS, VERBS } from './verbs.ts'
9
+ import { chatToolName, VERB_SPECS, VERBS, type Verb } from './verbs.ts'
10
10
 
11
- /** Register every verb. @returns the function that removes them all; it also aborts the calls still running. */
12
- export function registerChatTools(tools: ToolsPort, door: Door): () => void {
11
+ /** Register the verbs (default: every one). @returns the function that removes them all; it also aborts the calls still running. */
12
+ export function registerChatTools(tools: ToolsPort, door: Door, verbs: readonly Verb[] = VERBS): () => void {
13
13
  const running = new AbortController()
14
14
  const removers: Array<() => void> = []
15
15
  const removeAll = (): void => {
@@ -17,14 +17,16 @@ export function registerChatTools(tools: ToolsPort, door: Door): () => void {
17
17
  for (const remove of removers.splice(0).reverse()) remove()
18
18
  }
19
19
  try {
20
- for (const verb of VERBS) {
20
+ for (const verb of verbs) {
21
21
  removers.push(tools.register(chatToolName(verb), {
22
22
  description: VERB_SPECS[verb].description,
23
23
  // The model is told which fields the verb takes (an `act` needs its `op`), the same schema MCP lists.
24
24
  parameters: VERB_SPECS[verb].arguments as unknown as Readonly<Record<string, unknown>>,
25
- run: async (input: unknown) => {
25
+ // The run's own signal and page come with the call: its look and act go to the page the run was asked from.
26
+ run: async (input: unknown, options?: { signal?: AbortSignal; page?: string }) => {
27
+ const signal = options?.signal === undefined ? running.signal : AbortSignal.any([running.signal, options.signal])
26
28
  try {
27
- return (await door.run(verb, input ?? {}, running.signal)).text
29
+ return (await door.run(verb, input ?? {}, signal, options?.page === undefined ? {} : { page: options.page })).text
28
30
  } catch (cause) {
29
31
  if (cause instanceof DoorError || cause instanceof UiControlError) throw new Error(`${cause.code}: ${cause.message}`, { cause })
30
32
  throw cause