acrylblends 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +45 -1
  3. package/dist/index.js +1980 -27
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/getting-started/desktop.md +23 -4
  7. package/docs/framework/guides/create-acrylblends.md +1 -1
  8. package/docs/framework/workshop/README.md +138 -152
  9. package/docs/framework/workshop/facilitator.md +69 -101
  10. package/legacy/README.md +9 -0
  11. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  12. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  13. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  14. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  15. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  16. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  17. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  18. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  19. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  20. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  21. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  22. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  23. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  24. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  25. package/legacy/agent/855359304707/src/app.ts +168 -0
  26. package/legacy/agent/855359304707/src/routes.ts +126 -0
  27. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  28. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  29. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  30. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  31. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  32. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  33. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  34. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  35. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  36. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  37. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  38. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  39. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  40. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  41. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  42. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  43. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  44. package/legacy/layouts.json +524 -0
  45. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  46. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  47. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  48. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  49. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  50. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  51. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  52. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  53. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  54. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  55. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  56. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  57. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  58. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  59. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  60. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  61. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  62. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  63. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  64. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  65. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  66. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  67. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  68. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  69. package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
  70. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  71. package/lib/cordis/door.d.ts +42 -7
  72. package/lib/cordis/door.js +204 -60
  73. package/lib/cordis/inspect.js +1 -1
  74. package/lib/cordis/node-server.d.ts +101 -3
  75. package/lib/cordis/node-server.js +3 -3
  76. package/lib/cordis/router-http.d.ts +7 -1
  77. package/lib/cordis/router-http.js +44 -3
  78. package/lib/cordis/router.d.ts +1 -1
  79. package/lib/cordis/router.js +1 -1
  80. package/lib/cordis/state.js +1 -1
  81. package/lib/door/host.d.ts +2 -2
  82. package/lib/door/host.js +1 -1
  83. package/lib/door/page.d.ts +10 -1
  84. package/lib/door/page.js +46 -7
  85. package/lib/door/protocol.d.ts +1 -1
  86. package/lib/drivers.d.ts +10 -1
  87. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  88. package/lib/node-server.d.ts +7 -1
  89. package/lib/node-server.js +2 -2
  90. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  91. package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
  92. package/lib/src-CtkIX_Gg.js +427 -0
  93. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  94. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  95. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  96. package/package.json +2 -1
  97. package/plugins/acryl-door/src/host/channel.ts +86 -9
  98. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  99. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  100. package/plugins/acryl-door/src/host/door.ts +53 -23
  101. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  102. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  103. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  104. package/plugins/acryl-door/src/index.ts +166 -29
  105. package/plugins/acryl-door/src/page/base.ts +32 -0
  106. package/plugins/acryl-door/src/page/channel.ts +3 -1
  107. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  108. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  109. package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
  110. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  111. package/plugins/acryl-door/src/page/index.ts +41 -2
  112. package/plugins/acryl-door/src/page/outline.ts +56 -0
  113. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  114. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  115. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  116. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  117. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  118. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  119. package/plugins/acryl-node-server/src/index.ts +304 -16
  120. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  121. package/plugins/acryl-page/plugin.json +12 -0
  122. package/plugins/acryl-page/src/index.ts +163 -0
  123. package/plugins/acryl-router/src/service.ts +77 -14
  124. package/plugins/acryl-router-http/src/index.ts +15 -1
  125. package/plugins/acryl-router-http/src/serve.ts +31 -0
  126. package/plugins/acryl-web-server/plugin.json +14 -0
  127. package/plugins/acryl-web-server/src/index.ts +164 -0
  128. package/plugins/agent/agent.ts +38 -7
  129. package/plugins/agent/index.ts +1 -1
  130. package/plugins/agent/model.ts +10 -2
  131. package/plugins/agent/tools.ts +9 -3
  132. package/plugins/agent-panel/client/describe.ts +57 -0
  133. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  134. package/plugins/agent-panel/client/frame.ts +51 -0
  135. package/plugins/agent-panel/client/main.ts +238 -26
  136. package/plugins/agent-panel/drivers.ts +7 -2
  137. package/plugins/agent-panel/headless.ts +5 -2
  138. package/plugins/agent-panel/index.ts +146 -57
  139. package/plugins/agent-panel/mount.ts +27 -0
  140. package/plugins/agent-panel/visitors.ts +231 -0
  141. package/plugins/desktop/frame.ts +231 -0
  142. package/plugins/desktop/launch.ts +44 -9
  143. package/plugins/desktop/main.ts +100 -32
  144. package/plugins/desktop/window.ts +21 -4
  145. package/plugins/driver-pty/client/terminal.ts +4 -1
  146. package/plugins/driver-pty/index.ts +38 -11
  147. package/plugins/driver-pty/spawn.ts +38 -0
  148. package/plugins/driver-pty/terminal.ts +2 -0
  149. package/plugins/extensions/index.ts +26 -1
  150. package/plugins/extensions/resolve.ts +13 -0
  151. package/plugins/feedback/client.d.ts +22 -0
  152. package/plugins/feedback/client.js +471 -0
  153. package/plugins/feedback/index.ts +143 -0
  154. package/plugins/feedback/plugin.json +11 -0
  155. package/plugins/feedback/store.ts +307 -0
  156. package/plugins/home/index.ts +122 -0
  157. package/plugins/home/plugin.json +11 -0
  158. package/plugins/instance/index.ts +6 -0
  159. package/plugins/manifest/app-plugins.ts +86 -0
  160. package/plugins/manifest/index.ts +12 -2
  161. package/plugins/manifest/snapshot.ts +275 -0
  162. package/plugins/openai/index.ts +64 -5
  163. package/plugins/openai/prompt.ts +23 -0
  164. package/shared/AGENTS.core.md +36 -16
  165. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  166. package/shared/skills/acryl-app/SKILL.md +13 -0
  167. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  168. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  169. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  170. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  171. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  172. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  173. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  174. package/templates/agent/AGENTS.md +1 -1
  175. package/templates/agent/blend.yaml +4 -0
  176. package/templates/agent/gitignore +1 -0
  177. package/templates/agent/package.json +1 -0
  178. package/templates/agent/src/app.ts +150 -33
  179. package/templates/agent/src/frame.ts +17 -0
  180. package/templates/agent/src/main.ts +5 -3
  181. package/templates/agent/src/routes.ts +38 -19
  182. package/templates/agent/template.json +2 -0
  183. package/templates/agent/test/app.test.ts +45 -3
  184. package/templates/blank/AGENTS.md +3 -2
  185. package/templates/blank/blend.yaml +11 -0
  186. package/templates/blank/gitignore +1 -0
  187. package/templates/blank/package.json +1 -0
  188. package/templates/blank/src/app.ts +100 -29
  189. package/templates/blank/src/door.ts +7 -0
  190. package/templates/blank/src/first-page.ts +5 -0
  191. package/templates/blank/src/frame.ts +17 -0
  192. package/templates/blank/src/routes.ts +65 -8
  193. package/templates/blank/template.json +14 -1
  194. package/templates/blank/test/app.test.ts +103 -16
  195. package/templates/chat/blend.yaml +2 -1
  196. package/templates/chat/gitignore +1 -0
  197. package/templates/chat/package.json +1 -0
  198. package/templates/chat/src/app.ts +72 -22
  199. package/templates/chat/src/chat-app.ts +11 -2
  200. package/templates/chat/src/client/chat.tsx +4 -2
  201. package/templates/chat/src/frame.ts +17 -0
  202. package/templates/chat/src/page.tsx +6 -4
  203. package/templates/chat/src/routes.ts +14 -4
  204. package/templates/chat/src/runtime.ts +9 -3
  205. package/templates/chat/template.json +3 -1
  206. package/templates/chat/test/chat.test.ts +46 -1
  207. package/templates/chat/test/server.test.ts +17 -3
  208. package/templates/nodejs/blend.yaml +2 -1
  209. package/templates/nodejs/gitignore +1 -0
  210. package/templates/nodejs/package.json +1 -0
  211. package/templates/nodejs/src/app.ts +76 -21
  212. package/templates/nodejs/src/frame.ts +17 -0
  213. package/templates/nodejs/src/page.tsx +3 -3
  214. package/templates/nodejs/src/routes.ts +32 -13
  215. package/templates/nodejs/template.json +3 -1
  216. package/templates/nodejs/test/app.test.ts +38 -3
  217. package/templates/nodejs/test/server.test.ts +17 -3
  218. package/templates/notes/blend.yaml +2 -1
  219. package/templates/notes/gitignore +1 -0
  220. package/templates/notes/package.json +1 -0
  221. package/templates/notes/src/app.ts +72 -22
  222. package/templates/notes/src/frame.ts +17 -0
  223. package/templates/notes/src/notes-app.ts +24 -17
  224. package/templates/notes/src/routes.ts +11 -4
  225. package/templates/notes/src/runtime.ts +9 -3
  226. package/templates/notes/src/views.tsx +26 -17
  227. package/templates/notes/template.json +3 -1
  228. package/templates/notes/test/notes.test.ts +39 -1
  229. package/templates/notes/test/server.test.ts +17 -3
  230. package/templates/todo/blend.yaml +3 -0
  231. package/templates/todo/gitignore +1 -0
  232. package/templates/todo/package.json +1 -0
  233. package/templates/todo/src/app.ts +81 -27
  234. package/templates/todo/src/door.ts +7 -0
  235. package/templates/todo/src/frame.ts +17 -0
  236. package/templates/todo/src/page.ts +13 -6
  237. package/templates/todo/src/todo.ts +18 -6
  238. package/templates/todo/template.json +11 -1
  239. package/templates/todo/test/todo.test.ts +67 -9
  240. package/lib/src-LX0E4ec4.js +0 -117
  241. package/shared/SKILL.md +0 -14
@@ -7,6 +7,8 @@
7
7
  * response body still streaming and waits for all of it to settle.
8
8
  */
9
9
 
10
+ import { appendFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
10
12
  import { Context, Service } from '@deepseek-ai/cordis'
11
13
  import z from '@deepseek-ai/schemastery'
12
14
  import type { ConnectionInfo, RouterBindings } from './connection.ts'
@@ -21,6 +23,27 @@ import type { ErrorHandler, H, NotFoundHandler } from './core/types.ts'
21
23
 
22
24
  const DEFAULT_DISPOSE_GRACE_MS = 2000
23
25
 
26
+ /** How long disposal waits for the flights it had to abort after the drain, before it continues without them. */
27
+ const ABORT_SETTLE_MS = 300
28
+
29
+ /** 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. */
30
+ const traceFile = (): string => process.env.ACRYL_ROUTER_TRACE_FILE ?? `${tmpdir()}/acryl-router-trace.log`
31
+ const trace = (line: string): void => {
32
+ if (process.env.ACRYL_ROUTER_TRACE !== '1') return
33
+ try { appendFileSync(traceFile(), `${String(Date.now())} [${String(process.pid)}] ${line}\n`) } catch { /* tracing never affects a request */ }
34
+ }
35
+ let flightSeq = 0
36
+ const SIGNAL_TAG = Symbol.for('acryl.router.signalTag')
37
+ const tagOf = (signal: AbortSignal | undefined): string => {
38
+ if (signal === undefined) return 'none'
39
+ const tag = (signal as unknown as Record<symbol, unknown>)[SIGNAL_TAG]
40
+ return typeof tag === 'string' ? tag : 'untagged'
41
+ }
42
+ const tagSignal = (signal: AbortSignal, tag: string): void => {
43
+ try { (signal as unknown as Record<symbol, unknown>)[SIGNAL_TAG] = tag } catch { /* a frozen signal keeps no tag */ }
44
+ }
45
+
46
+
24
47
  /** How a trailing slash is read: `strict` keeps `/a` and `/a/` distinct, `ignore` treats them as one path. */
25
48
  export type TrailingSlash = 'strict' | 'ignore'
26
49
 
@@ -105,6 +128,10 @@ interface RouterState {
105
128
 
106
129
  interface Flight {
107
130
  readonly controller: AbortController
131
+ /** The request the handlers run on (the caller's request with the flight's signal): held strongly for the flight's lifetime, so its signal cannot be collected under a listener. */
132
+ readonly inner: Request
133
+ /** Names the flight in the ACRYL_ROUTER_TRACE diagnostics. */
134
+ readonly tag: string
108
135
  /** How many of each mount's handlers are running for this request right now (a middleware counts until its `next()` returns). */
109
136
  readonly running: Map<Mount, number>
110
137
  /** The mount whose handler produced the response; its body is that mount's code still running. */
@@ -150,8 +177,8 @@ export class Router extends Service {
150
177
  }
151
178
  ctx.effect(() => async () => {
152
179
  this.state.closing = true
153
- await this.end([...this.flights], 'disposal')
154
- }, 'acryl-router: abort and settle in-flight requests')
180
+ await this.drain()
181
+ }, 'acryl-router: drain in-flight requests, then abort what is left')
155
182
  }
156
183
 
157
184
  /** Registrations currently held (mounts, and the router-wide error and not-found handlers); a leak check for reload tests. */
@@ -168,12 +195,16 @@ export class Router extends Service {
168
195
  const mount = this.snapshot(path, app)
169
196
  this.state.mounts.add(mount)
170
197
  this.state.composed = undefined
171
- // Removing a mount ends the requests its code is still serving: their signals abort, a body it produced is cancelled, and the
172
- // plugin's unload waits for them (bounded by `disposeGraceMs`), so nothing a plugin started outlives it.
198
+ // Removing a mount ends the requests its code is still serving: they get the grace to finish first (a save being written when its plugin unloads,
199
+ // or when the whole app stops and every plugin unloads together, must not be cut), and only then are their signals aborted, their bodies cancelled
200
+ // and their settle awaited, so nothing a plugin started outlives it by more than that bounded grace.
173
201
  const remove = (): Promise<void> => {
174
202
  if (!this.state.mounts.delete(mount)) return Promise.resolve()
175
203
  this.state.composed = undefined
176
- return this.end([...this.flights].filter(flight => (flight.running.get(mount) ?? 0) > 0 || flight.producer === mount), `removing ${path}`)
204
+ return this.drainFlights(
205
+ [...this.flights].filter(flight => (flight.running.get(mount) ?? 0) > 0 || flight.producer === mount),
206
+ `removing ${path}`,
207
+ )
177
208
  }
178
209
  // Each route the mount serves is a child effect of it, labeled on its own, so the inspector can report routes without asking the
179
210
  // router and no label grows with the size of the app (`ALL` is middleware or `all`). The children hold nothing: the mount does.
@@ -215,6 +246,7 @@ export class Router extends Service {
215
246
  const handler: H = async (c, next) => {
216
247
  const flight = this.flightOf.get(c.req.raw)
217
248
  if (flight === undefined) return inner(c, next)
249
+ 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)}`)
218
250
  let calledNext = false
219
251
  flight.running.set(mount, (flight.running.get(mount) ?? 0) + 1)
220
252
  try {
@@ -234,18 +266,46 @@ export class Router extends Service {
234
266
  return mount
235
267
  }
236
268
 
237
- /** Abort `flights`, cut their bodies short and wait for them to settle, at most `disposeGraceMs`. */
238
- private async end(flights: readonly Flight[], why: string): Promise<void> {
269
+ /**
270
+ * 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
271
+ * 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
272
+ * its abort signal.
273
+ */
274
+ private async drain(): Promise<void> {
275
+ await this.drainFlights([...this.flights], 'disposal')
276
+ }
277
+
278
+ /** The one draining rule: let `flights` finish inside the grace, then abort and settle what is left. */
279
+ private async drainFlights(flights: readonly Flight[], why: string): Promise<void> {
280
+ trace(`drain ${why} chosen=[${flights.map(flight => flight.tag).join(', ')}]`)
281
+ if (flights.length === 0) return
282
+ if (await this.settledWithin(flights, this.graceMs)) { trace(`drain ${why} settled inside the grace`); return }
283
+ const left = flights.filter(flight => this.flights.has(flight))
284
+ trace(`drain ${why} grace over left=[${left.map(flight => flight.tag).join(', ')}]`)
285
+ if (left.length > 0) await this.end(left, why, ABORT_SETTLE_MS)
286
+ }
287
+
288
+ /** Resolve true when every flight settled inside `ms`, false at the deadline. */
289
+ private async settledWithin(flights: readonly Flight[], ms: number): Promise<boolean> {
290
+ let timer: ReturnType<typeof setTimeout> | undefined
291
+ const gaveUp = new Promise<false>(resolve => { timer = setTimeout(() => { resolve(false) }, ms) })
292
+ const outcome = await Promise.race([Promise.allSettled(flights.map(flight => flight.settled)).then(() => true as const), gaveUp])
293
+ clearTimeout(timer)
294
+ return outcome
295
+ }
296
+
297
+ /** Abort `flights`, cut their bodies short and wait for them to settle, at most `settleMs`. */
298
+ private async end(flights: readonly Flight[], why: string, settleMs: number = this.graceMs): Promise<void> {
239
299
  if (flights.length === 0) return
240
300
  for (const flight of flights) {
301
+ trace(`abort ${flight.tag} before=${String(flight.controller.signal.aborted)}`)
241
302
  flight.controller.abort()
303
+ trace(`abort ${flight.tag} after=${String(flight.controller.signal.aborted)} innerAborted=${String(flight.inner.signal.aborted)}`)
304
+ queueMicrotask(() => { trace(`post-abort ${flight.tag} innerAborted=${String(flight.inner.signal.aborted)}`) })
242
305
  flight.endBody()
243
306
  }
244
- let timer: ReturnType<typeof setTimeout> | undefined
245
- const gaveUp = new Promise<'gave-up'>(resolve => { timer = setTimeout(() => { resolve('gave-up') }, this.graceMs) })
246
- const outcome = await Promise.race([Promise.allSettled(flights.map(flight => flight.settled)).then(() => 'settled' as const), gaveUp])
247
- clearTimeout(timer)
248
- 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`)
307
+ if (await this.settledWithin(flights, settleMs)) return
308
+ this.ctx.logger.warn(`acryl-router: ${String(flights.length)} request(s) did not finish ${String(settleMs)} ms after abort (${why}); continued without them`)
249
309
  }
250
310
 
251
311
  /** Turn errors no mounted app handled into responses. One handler per router; the calling plugin owns it. */
@@ -292,7 +352,7 @@ export class Router extends Service {
292
352
  * `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.
293
353
  */
294
354
  fetch(request: Request, connection: ConnectionInfo = {}, bindings?: object): Promise<Response> {
295
- if (this.state.closing) return Promise.resolve(new Response('Service Unavailable', { status: 503 }))
355
+ if (this.state.closing) return Promise.resolve(new Response('Service Unavailable', { status: 503, headers: { connection: 'close' } }))
296
356
  const controller = new AbortController()
297
357
  const forward = (): void => controller.abort()
298
358
  if (request.signal.aborted) controller.abort()
@@ -308,8 +368,11 @@ export class Router extends Service {
308
368
  let bodyDone: Promise<void> = Promise.resolve()
309
369
  let endBody = (): void => {}
310
370
  let resolveSettled!: (value: Promise<unknown>) => void
311
- const flight: Flight = { controller, running: new Map(), producer: undefined, endBody: () => { endBody() }, settled: new Promise(resolve => { resolveSettled = resolve }) }
371
+ const tag = `f${String(++flightSeq)}`
312
372
  const inner = new Request(request, { signal: controller.signal })
373
+ tagSignal(inner.signal, tag)
374
+ const flight: Flight = { controller, inner, tag, running: new Map(), producer: undefined, endBody: () => { endBody() }, settled: new Promise(resolve => { resolveSettled = resolve }) }
375
+ trace(`create ${tag} ${request.method} ${new URL(request.url).pathname}`)
313
376
  this.flightOf.set(inner, flight)
314
377
  // The caller gets the Response the moment the handler returns it; the flight lasts until its body ends and every waitUntil task settles.
315
378
  const response = this.dispatch(inner, env, executionCtx).then(raw => {
@@ -41,6 +41,10 @@ export class RouterHttp extends Service implements RouterHttpService {
41
41
  private readonly router: Context['router']
42
42
  private readonly web: WebServerPort
43
43
  private hub: UpgradeHub | undefined
44
+ /** One upgrade helper per calling context: a fresh wrapper per call made every service event look like an owner change. */
45
+ private readonly upgradeHelpers = new WeakMap<object, UpgradeWebSocket>()
46
+ /** A stable identity for this service instance's hub: a caller that must recognize the same owner across calls keys on this, not on a helper. */
47
+ private readonly hubIdentity: object = {}
44
48
  /** The adapter's own context: the hub lives as long as the adapter, whichever plugin asked for it first (`this.ctx` is the caller's). */
45
49
  private readonly home: Context
46
50
  private readonly upgradesIn = new UpgradeEmitter()
@@ -99,8 +103,18 @@ export class RouterHttp extends Service implements RouterHttpService {
99
103
  * `upgradeWebSocket` for the calling plugin: the sockets its routes accept are closed with 1001 when it unloads. Needs the `ws` package.
100
104
  * The route must be on a path the plugin mounted with `upgrade: [path]`.
101
105
  */
106
+ /** Stable for this service's lifetime; a re-provided adapter has a new one, which is how a caller sees a new owner. */
107
+ hubToken(): object {
108
+ return this.hubIdentity
109
+ }
110
+
102
111
  upgrades(): UpgradeWebSocket {
103
- return this.ensureHub().upgradeWebSocketFor(this.ctx) as unknown as UpgradeWebSocket
112
+ const owner = this.ctx
113
+ const cached = this.upgradeHelpers.get(owner)
114
+ if (cached !== undefined) return cached
115
+ const created = this.ensureHub().upgradeWebSocketFor(owner) as unknown as UpgradeWebSocket
116
+ this.upgradeHelpers.set(owner, created)
117
+ return created
104
118
  }
105
119
 
106
120
  /** How many WebSocket connections are open now. */
@@ -6,6 +6,35 @@ export interface FetchRouter {
6
6
  fetch(request: Request, connection?: { readonly remoteAddress?: string | undefined, readonly localPort?: number | undefined }): Promise<Response>
7
7
  }
8
8
 
9
+ /** How long a refusal waits for a request body the handler never read. */
10
+ export const REQUEST_BODY_SETTLE_MS = 1_000
11
+
12
+ /**
13
+ * Read and discard a request body the handler left unread (a guard's 403, a 415, a body over its limit), so the client can finish sending and
14
+ * read the answer. Ending a response while request data is still arriving makes Node close the socket with that data pending, and the peer sees a
15
+ * reset instead of the answer: reliably on Windows (the workspace git worktree test's read ECONNRESET) and on other systems under load. Bounded:
16
+ * a client that stops sending waits at most `ms`, then loses the socket as before.
17
+ */
18
+ export function settleRequestBody(req: IncomingMessage, ms: number = REQUEST_BODY_SETTLE_MS): Promise<void> {
19
+ if (req.readableEnded || req.destroyed) return Promise.resolve()
20
+ return new Promise<void>((resolve) => {
21
+ let timer: ReturnType<typeof setTimeout> | undefined
22
+ const done = (): void => {
23
+ if (timer !== undefined) clearTimeout(timer)
24
+ req.off('end', done)
25
+ req.off('close', done)
26
+ req.off('error', done)
27
+ resolve()
28
+ }
29
+ timer = setTimeout(done, ms)
30
+ timer.unref?.()
31
+ req.once('end', done)
32
+ req.once('close', done)
33
+ req.once('error', done)
34
+ req.resume()
35
+ })
36
+ }
37
+
9
38
  /**
10
39
  * Serve one node request through the router and write the result. The request's abort signal fires when the client
11
40
  * disconnects before the response finished; the socket's peer address and local port travel as connection info, never in the request.
@@ -20,5 +49,7 @@ export async function serve(router: FetchRouter, req: IncomingMessage, res: Serv
20
49
  } catch {
21
50
  response = new Response('Internal Server Error', { status: 500 })
22
51
  }
52
+ // The handler may have answered without reading the body; let the client finish sending before the answer ends the connection.
53
+ if (method !== 'GET' && method !== 'HEAD') await settleRequestBody(req)
23
54
  await sendResponse(res, response, method)
24
55
  }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "acryl-web-server",
3
+ "description": "The webServer port of a framework-hosted app: the node request/upgrade interface DSH's driver rows register on, backed by acryl-node-server's raw routes. Ours, so the chat driver's connection and gateway run on our host without DSH's web server.",
4
+ "requires": ["acryl-node-server"],
5
+ "inject": ["nodeServer"],
6
+ "provides": ["webServer"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ },
10
+ "mount": "mount it before the DSH driver rows that inject webServer (the chat connection and its gateway); the framework's page serving applies its index taps",
11
+ "entries": {
12
+ "": "src/index.ts"
13
+ }
14
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The `webServer` port for a framework-hosted app. DSH's driver rows (the chat's connection and its remote gateway) register node handlers with
3
+ * `webServer.register` and `webServer.registerUpgrade`; this plugin serves them on the framework's own node server, so those rows run unchanged
4
+ * while the HTTP host, the page and the client-module serving stay ours.
5
+ *
6
+ * It is the inverse of `acryl-router-http`: there the Host's web server serves a router prefix; here our node server presents the web-server
7
+ * interface a DSH row expects. The surface is deliberately small: `host` and `port` (what `acryl-web` reads), `register` (exact or prefix),
8
+ * `registerUpgrade` (one exact path), and the index taps the page serving applies. A row that needs more of DSH's interface gets the extra
9
+ * method here, named with the row that needs it - so this seam can shrink when a row goes away.
10
+ */
11
+ import type { IncomingMessage, ServerResponse } from 'node:http'
12
+ import type { Duplex } from 'node:stream'
13
+ import { Context, Service } from '@deepseek-ai/cordis'
14
+ import type { NodeServer, RawRoute, RawUpgradeRoute } from 'acryl-node-server'
15
+
16
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
17
+ export const name = 'acryl-web-server'
18
+
19
+ /** Hard requirement: the node server whose raw routes carry these handlers. */
20
+ export const inject = ['nodeServer']
21
+
22
+ /** A node handler at a path `exact` (the pathname itself) or a `prefix` (the path and everything below it). */
23
+ export interface WebServerRoute {
24
+ readonly kind: 'prefix' | 'exact'
25
+ readonly path: string
26
+ readonly handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
27
+ }
28
+
29
+ /** One exact-path upgrade handler; the socket belongs to the handler once its path matches. */
30
+ export interface WebServerUpgradeRoute {
31
+ readonly path: string
32
+ readonly handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
33
+ }
34
+
35
+ /** Transforms the served index document, in registration order. */
36
+ export type IndexTap = (html: string) => string
37
+
38
+ /** What a driver row (or `acryl-web`) reads from `webServer`. */
39
+ export interface WebServerService {
40
+ /** The address the node server bound. */
41
+ readonly host: string
42
+ readonly port: number
43
+ /** Serve `route` until the returned remover runs or the calling plugin unloads. A path already served throws. */
44
+ register(route: WebServerRoute): () => void
45
+ /** Claim `route.path` for an upgrade until the returned remover runs or the calling plugin unloads. */
46
+ registerUpgrade(route: WebServerUpgradeRoute): () => void
47
+ /** Transform the served index document while the caller lives; returns its remover. */
48
+ tapIndex(transform: IndexTap): () => void
49
+ /** The document with the structured injection rows (the client modules' boot wire) and then every live tap applied. */
50
+ index(html: string): string
51
+ }
52
+
53
+ declare module '@deepseek-ai/cordis' {
54
+ interface Context {
55
+ webServer: WebServerService
56
+ }
57
+ }
58
+
59
+ /** One structured index-injection row (DSH's contract): the kinds the client modules and a door script use. */
60
+ interface IndexRow {
61
+ readonly kind?: string
62
+ readonly placement?: 'head' | 'body'
63
+ readonly text?: string
64
+ readonly src?: string
65
+ readonly name?: string
66
+ readonly value?: unknown
67
+ }
68
+
69
+ /** The boot-readiness tail the client shell waits for, as DSH renders it. */
70
+ const READY_MARKUP = '<script>(globalThis.__DSH_BOOT_READY__ ??= Promise.withResolvers()).resolve()</script>'
71
+
72
+ /** A JSON literal safe inside a `<script>`: a sequence like `</script>` must not end it. */
73
+ const scriptJson = (value: unknown): string => JSON.stringify(value).replaceAll('</', '<\\/')
74
+
75
+ /** Render one row; an unknown kind is skipped rather than emitted as broken markup. */
76
+ function rowMarkup(row: IndexRow): string {
77
+ if (row.kind === 'script') return `<script>${row.text ?? ''}</script>`
78
+ if (row.kind === 'script-src') return `<script type="module" crossorigin src="${row.src ?? ''}"></script>`
79
+ if (row.kind === 'script-preload') return `<link rel="modulepreload" crossorigin href="${row.src ?? ''}" />`
80
+ if (row.kind === 'global') return `<script>globalThis[${scriptJson(row.name ?? '')}] = ${scriptJson(row.value)}</script>`
81
+ return ''
82
+ }
83
+
84
+ /** Insert each group after its opening tag, as DSH's served index does; a document without the tag gets the group first. */
85
+ export function renderIndexRows(html: string, rows: readonly IndexRow[]): string {
86
+ let head = ''
87
+ let body = ''
88
+ for (const row of rows) {
89
+ const markup = rowMarkup(row)
90
+ if (markup === '') continue
91
+ if (row.placement === 'body') body += markup
92
+ else head += markup
93
+ }
94
+ body += READY_MARKUP
95
+ let document = html
96
+ const headOpen = /<head(?:\s[^>]*)?>/iu.exec(document)
97
+ if (head !== '') {
98
+ const at = headOpen === null ? 0 : headOpen.index + headOpen[0].length
99
+ document = `${document.slice(0, at)}${head}${document.slice(at)}`
100
+ }
101
+ const bodyOpen = /<body(?:\s[^>]*)?>/iu.exec(document)
102
+ if (body !== '') {
103
+ const at = bodyOpen === null ? document.length : bodyOpen.index + bodyOpen[0].length
104
+ document = `${document.slice(0, at)}${body}${document.slice(at)}`
105
+ }
106
+ return document
107
+ }
108
+
109
+ /** The port, backed by `nodeServer`'s raw routes; every registration belongs to the plugin that made it. */
110
+ export class WebServer extends Service implements WebServerService {
111
+ private readonly taps: IndexTap[] = []
112
+
113
+ constructor(ctx: Context) {
114
+ super(ctx, 'webServer')
115
+ }
116
+
117
+ get host(): string {
118
+ return this.ctx.nodeServer.hostname
119
+ }
120
+
121
+ get port(): number {
122
+ return this.ctx.nodeServer.port
123
+ }
124
+
125
+ register(route: WebServerRoute): () => void {
126
+ const remove = this.ctx.nodeServer.registerRaw(route as RawRoute)
127
+ this.ctx.effect(() => () => { remove() }, `acryl-web-server: ${route.kind} ${route.path}`)
128
+ return remove
129
+ }
130
+
131
+ registerUpgrade(route: WebServerUpgradeRoute): () => void {
132
+ const remove = this.ctx.nodeServer.registerRawUpgrade(route as RawUpgradeRoute)
133
+ this.ctx.effect(() => () => { remove() }, `acryl-web-server: upgrade ${route.path}`)
134
+ return remove
135
+ }
136
+
137
+ tapIndex(transform: IndexTap): () => void {
138
+ this.taps.push(transform)
139
+ const remove = (): void => {
140
+ const at = this.taps.indexOf(transform)
141
+ if (at >= 0) this.taps.splice(at, 1)
142
+ }
143
+ this.ctx.effect(() => () => { remove() }, 'acryl-web-server: index tap')
144
+ return remove
145
+ }
146
+
147
+ index(html: string): string {
148
+ // The rows are DSH's index-injection contract (`webserver/index-inject`): the client-module row pushes its boot wire there. The rows render
149
+ // first, the string taps after, the same order DSH's own web server uses.
150
+ const rows: IndexRow[] = []
151
+ ;(this.ctx as unknown as { emit(name: string, table: IndexRow[]): void }).emit('webserver/index-inject', rows)
152
+ let document = renderIndexRows(html, rows)
153
+ for (const tap of [...this.taps]) document = tap(document)
154
+ return document
155
+ }
156
+ }
157
+
158
+ /** Provide `webServer` on this context; the node server must already be up (the plugin injects it). */
159
+ export function apply(ctx: Context): void {
160
+ new WebServer(ctx)
161
+ }
162
+
163
+ /** Re-exported for callers that shape their own routes: the raw types the port is built on. */
164
+ export type { NodeServer, RawRoute, RawUpgradeRoute }
@@ -24,6 +24,8 @@ export type RunResult = (
24
24
  export interface RunOptions {
25
25
  /** Aborting it stops the run: the model call or tool in flight is dropped and the run ends `{ status: 'cancelled' }`. */
26
26
  readonly signal?: AbortSignal
27
+ /** The door's id of the page the run was asked from: every tool call carries it, so the page tools drive that page and no other (a production assistant serves many visitors). */
28
+ readonly page?: string
27
29
  }
28
30
 
29
31
  export interface Agent {
@@ -39,6 +41,16 @@ export interface Budget {
39
41
  export interface AgentConfig {
40
42
  /** Steps per run before it stops. */
41
43
  steps?: number
44
+ /**
45
+ * The only tools a run may see and call; absent: every tool in the registry. A production assistant gets the page tools alone (`door_look`, `door_act`), so whatever
46
+ * a visitor asks, the agent can use the app and never change it. A call to any other name fails as an unknown tool would.
47
+ */
48
+ tools?: readonly string[]
49
+ /**
50
+ * The tool whose result an answer must come right after. When the model answers after another tool call (it acted and did not look), the agent calls this tool
51
+ * itself, once, and asks the model again with what it shows. The production assistant sets `door_look`, so what it tells a visitor is what the page shows now.
52
+ */
53
+ answerAfter?: string
42
54
  }
43
55
 
44
56
  /**
@@ -91,6 +103,8 @@ export const agent = {
91
103
  inject: ['model', 'tools', 'memory'],
92
104
  apply(ctx: Context, config?: AgentConfig) {
93
105
  const limit = config?.steps ?? 6
106
+ const allowed = config?.tools === undefined ? undefined : new Set(config.tools)
107
+ const answerAfter = config?.answerAfter
94
108
  let runs = 0
95
109
  ctx.provide('agent', {
96
110
  async run(question, options) {
@@ -104,13 +118,15 @@ export const agent = {
104
118
  )
105
119
  // Assigned inside the run's fiber; `as` keeps TypeScript from narrowing it to this first value.
106
120
  let result = { status: 'budget_exceeded', steps: 0, trace } as RunResult
107
- const task = ctx.plugin({
121
+ // Each run's `budget` is its own: runs may overlap (the production Assistant serves visitors at once), and a service name is shared unless isolated.
122
+ const task = ctx.isolate('budget').plugin({
108
123
  name: `task-${runs}`,
109
124
  inject: ['model', 'tools', 'memory'],
110
125
  async apply(scope: Context) {
111
126
  scope.provide('budget', { steps: 0, limit } satisfies Budget)
112
127
  const budget = scope.budget
113
128
  let toolResult: unknown
129
+ let looked = false
114
130
  while (budget.steps < budget.limit) {
115
131
  if (signal.aborted) {
116
132
  result = { status: 'cancelled', steps: budget.steps, trace }
@@ -124,7 +140,7 @@ export const agent = {
124
140
  step: budget.steps,
125
141
  toolResult,
126
142
  trace: [...trace],
127
- tools: scope.tools.list(),
143
+ tools: scope.tools.list().filter((tool) => allowed?.has(tool.name) ?? true),
128
144
  signal,
129
145
  }),
130
146
  signal,
@@ -136,16 +152,31 @@ export const agent = {
136
152
  }
137
153
  budget.steps += 1
138
154
  if (step.tool === undefined) {
139
- scope.memory.remember(`${question} => ${step.text}`)
140
- result = { status: 'completed', output: step.text, steps: budget.steps, trace }
141
- return
142
- }
155
+ const last = trace.at(-1)
156
+ const unseen =
157
+ answerAfter !== undefined &&
158
+ last !== undefined &&
159
+ last.tool !== answerAfter &&
160
+ !looked
161
+ if (!unseen) {
162
+ scope.memory.remember(`${question} => ${step.text}`)
163
+ result = { status: 'completed', output: step.text, steps: budget.steps, trace }
164
+ return
165
+ }
166
+ step = { tool: answerAfter, input: {} }
167
+ looked = true
168
+ } else looked = false
143
169
  const call = record
144
170
  ? recorder?.start(`tool:${step.tool}`, step.input, { parent: record.id })
145
171
  : undefined
146
172
  try {
173
+ if (allowed !== undefined && !allowed.has(step.tool))
174
+ throw new Error(`no tool named "${step.tool}" is allowed in this run`)
147
175
  toolResult = await until(
148
- scope.tools.call(step.tool, step.input, { signal }),
176
+ scope.tools.call(step.tool, step.input, {
177
+ signal,
178
+ ...(options?.page === undefined ? {} : { page: options.page }),
179
+ }),
149
180
  signal,
150
181
  )
151
182
  trace.push({
@@ -17,4 +17,4 @@ export {
17
17
  type Step,
18
18
  scripted,
19
19
  } from './model.ts'
20
- export { type Tool, type Tools, tools } from './tools.ts'
20
+ export { type CallOptions, type Tool, type Tools, tools } from './tools.ts'
@@ -52,8 +52,16 @@ declare module '@deepseek-ai/cordis' {
52
52
  }
53
53
 
54
54
  const builtIn: Rule[] = [
55
- ({ toolResult, step }) =>
56
- step > 0 ? { text: `The answer is ${String(toolResult)}.` } : undefined,
55
+ ({ toolResult, step }) => {
56
+ if (step === 0) return undefined
57
+ // A failed call comes back as `{ error }`: say what failed, not "[object Object]".
58
+ const failed = typeof toolResult === 'object' && toolResult !== null && 'error' in toolResult
59
+ return {
60
+ text: failed
61
+ ? `That did not work: ${String(toolResult.error)}.`
62
+ : `The answer is ${String(toolResult)}.`,
63
+ }
64
+ },
57
65
  ({ question }) => {
58
66
  const sum = /^add\s+(-?\d+)\s+(-?\d+)$/.exec(question.trim())
59
67
  return sum ? { tool: 'add', input: [Number(sum[1]), Number(sum[2])] } : undefined
@@ -1,12 +1,18 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
2
 
3
+ /** What a run passes with each call: its signal, and the door's id of the page it was asked from (the page tools drive only that page). */
4
+ export interface CallOptions {
5
+ readonly signal?: AbortSignal
6
+ readonly page?: string
7
+ }
8
+
3
9
  /** One tool: what it does (for the model) and how to run it. */
4
10
  export interface Tool {
5
11
  readonly description: string
6
12
  /** The JSON Schema of the input (an object schema), sent to a real model so it knows which fields the tool takes. A tool without one takes any object. */
7
13
  readonly parameters?: Readonly<Record<string, unknown>>
8
- /** `options.signal` aborts when the run is cancelled; a tool that can stop its work listens to it. */
9
- run(input: unknown, options?: { signal?: AbortSignal }): unknown | Promise<unknown>
14
+ /** `options.signal` aborts when the run is cancelled; a tool that can stop its work listens to it. `options.page` is the page the run was asked from, when it was. */
15
+ run(input: unknown, options?: CallOptions): unknown | Promise<unknown>
10
16
  }
11
17
 
12
18
  /** The tool registry. A plugin registers its tools inside `ctx.effect`, so they leave with it. */
@@ -18,7 +24,7 @@ export interface Tools {
18
24
  description: string
19
25
  parameters?: Readonly<Record<string, unknown>>
20
26
  }>
21
- call(name: string, input: unknown, options?: { signal?: AbortSignal }): Promise<unknown>
27
+ call(name: string, input: unknown, options?: CallOptions): Promise<unknown>
22
28
  }
23
29
 
24
30
  declare module '@deepseek-ai/cordis' {
@@ -42,3 +42,60 @@ export function describeTool(data: Record<string, unknown>): string | undefined
42
42
  const failed = data.error ? ` failed: ${clip(String(data.error))}` : ''
43
43
  return `${tool}${input}${failed}`
44
44
  }
45
+
46
+ /** What an act touched, from its result: the door's chat tool answers `done: link "My tasks" (detail); look again before the next act`, its verb `{ target }`. */
47
+ function targetOf(result: unknown): { role?: string; name?: string } {
48
+ if (typeof result === 'string') {
49
+ const match = /^done: (\S+) "(.*)"(?: \(.*\))?; look again/u.exec(result)
50
+ return match === null ? {} : { role: match[1], name: match[2] }
51
+ }
52
+ if (typeof result !== 'object' || result === null) return {}
53
+ const target = (result as { target?: unknown }).target
54
+ if (typeof target !== 'object' || target === null) return {}
55
+ const { role, name } = target as { role?: unknown; name?: unknown }
56
+ return {
57
+ ...(typeof role === 'string' ? { role } : {}),
58
+ ...(typeof name === 'string' ? { name } : {}),
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The Assistant's step on the page, as the person reads it under the answer: what it did, named the way the page names it. `ui.open("My tasks")`,
64
+ * `ui.fill("Due", "This week")`, `ui.click("Save")`, `ui.press("Enter")`. A look changes nothing, so it is left out unless it failed. The control's name comes from the
65
+ * act's result (the page says what it acted on); the ref is the fallback.
66
+ */
67
+ export function describePageStep(data: Record<string, unknown>): string | undefined {
68
+ const failed = data.error ? ' failed' : ''
69
+ if (data.tool === 'door_look') return failed === '' ? undefined : 'ui.look() failed'
70
+ if (data.tool !== 'door_act') return describeTool(data)
71
+ const input = (typeof data.input === 'object' && data.input !== null ? data.input : {}) as Record<
72
+ string,
73
+ unknown
74
+ >
75
+ const target = targetOf(data.result)
76
+ const quoted = (value: unknown): string => JSON.stringify(clip(String(value ?? '')))
77
+ const op = typeof input.op === 'string' ? input.op : 'act'
78
+ const named =
79
+ typeof target.name === 'string' && target.name !== ''
80
+ ? quoted(target.name)
81
+ : typeof input.ref === 'string'
82
+ ? input.ref
83
+ : undefined
84
+ const [verb, args]: [string, readonly (string | undefined)[]] =
85
+ op === 'navigate'
86
+ ? ['open', [quoted(input.path)]]
87
+ : op === 'click' && target.role === 'link'
88
+ ? ['open', [named]]
89
+ : op === 'type'
90
+ ? ['fill', [named, quoted(input.text)]]
91
+ : op === 'select'
92
+ ? ['fill', [named, quoted(input.option)]]
93
+ : op === 'press'
94
+ ? ['press', [quoted(input.key)]]
95
+ : op === 'scroll'
96
+ ? ['scroll', [input.direction === undefined ? undefined : quoted(input.direction)]]
97
+ : op === 'wait'
98
+ ? ['wait', [quoted(input.text ?? input.name)]]
99
+ : [op, [named]]
100
+ return `ui.${verb}(${args.filter((arg) => arg !== undefined).join(', ')})${failed}`
101
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Which driver pick a finished terminal open may apply to. `choose()` can be in flight when the person picks
3
+ * another driver (the panel also restores a remembered terminal at start), and only the latest pick may win: a
4
+ * stale terminal must not replace the form the person chose.
5
+ */
6
+ export interface PickGeneration {
7
+ /** Start a pick and return the token its result is settled with. */
8
+ begin(): number
9
+ /** True only while `generation` is still the latest pick. */
10
+ isCurrent(generation: number): boolean
11
+ }
12
+
13
+ export function createPickGeneration(): PickGeneration {
14
+ let generation = 0
15
+ return {
16
+ begin: () => {
17
+ generation += 1
18
+ return generation
19
+ },
20
+ isCurrent: (candidate) => candidate === generation,
21
+ }
22
+ }