acrylblends 1.0.9 → 1.1.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 (47) hide show
  1. package/dist/index.d.ts +15 -1
  2. package/dist/index.js +13 -7
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/api/summary.md +1 -1
  5. package/docs/framework/guides/bring-your-own-tool.md +2 -0
  6. package/docs/framework/guides/production.md +20 -0
  7. package/docs/framework/workshop/README.md +177 -274
  8. package/docs/framework/workshop/facilitator.md +107 -84
  9. package/lib/{audit-Dw-siYTp.d.ts → audit-BFpULBCj.d.ts} +15 -3
  10. package/lib/cordis/door.d.ts +3 -1
  11. package/lib/cordis/door.js +11 -1
  12. package/lib/cordis/state.d.ts +3 -2
  13. package/lib/cordis/state.js +370 -6
  14. package/lib/db/conformance.d.ts +6 -2
  15. package/lib/db/conformance.js +14 -2
  16. package/lib/door/host.d.ts +1 -1
  17. package/lib/door/host.js +1 -1
  18. package/lib/door/page.d.ts +10 -0
  19. package/lib/door/page.js +29 -7
  20. package/lib/kv/conformance.d.ts +6 -2
  21. package/lib/kv/conformance.js +14 -2
  22. package/lib/{snapshot-text-CBi3EMnj.js → snapshot-text-BwM6T2nG.js} +37 -5
  23. package/lib/vitest-binding-CctNu3jT.d.ts +15 -0
  24. package/lib/vitest-binding-CrH7rKg2.js +17 -0
  25. package/package.json +1 -5
  26. package/plugins/acryl-door/src/host/channel.ts +43 -5
  27. package/plugins/acryl-door/src/host/door.ts +5 -0
  28. package/plugins/acryl-door/src/host/page-routes.ts +1 -1
  29. package/plugins/acryl-door/src/host/page-socket.ts +1 -1
  30. package/plugins/acryl-door/src/page/channel.ts +8 -0
  31. package/plugins/acryl-door/src/page/driver/driver.ts +17 -3
  32. package/plugins/acryl-door/src/page/index.ts +6 -1
  33. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  34. package/plugins/acryl-state/plugin.json +4 -3
  35. package/plugins/acryl-state/src/client-source.ts +244 -0
  36. package/plugins/acryl-state/src/service.ts +33 -6
  37. package/plugins/acryl-state/src/socket.ts +96 -0
  38. package/plugins/agent-panel/client/main.ts +105 -55
  39. package/plugins/agent-panel/index.ts +42 -5
  40. package/plugins/desktop/main.ts +5 -1
  41. package/plugins/instance/index.ts +6 -3
  42. package/templates/agent/AGENTS.md +3 -1
  43. package/templates/agent/src/routes.ts +38 -5
  44. package/templates/agent/test/app.test.ts +38 -2
  45. package/templates/chat/src/main.ts +1 -6
  46. package/templates/nodejs/src/main.ts +1 -6
  47. package/templates/notes/src/main.ts +1 -6
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The browser module served at `/api/state/client.js`, kept as text so it travels with the plugin wherever the plugin goes (copied into an app as source, or bundled): there is
3
+ * no file to find at run time and no compiler needed to serve it. `String.raw` keeps every backslash as written, so what is below is the module exactly as the browser gets it.
4
+ * The code has no imports and uses no backtick or dollar-brace of its own.
5
+ *
6
+ * import { watch } from '/api/state/client.js'
7
+ * const stop = watch('tasks.count', (value) => { badge.textContent = String(value) })
8
+ *
9
+ * One WebSocket for the whole page carries every key; a WebSocket is not one of the browser's six connections per host, so a page that watches ten keys, in as many windows,
10
+ * leaves every one of them free for requests. Where the WebSocket cannot open (no `ws` package on the server, a proxy that does not upgrade), each key is followed by its
11
+ * event stream instead, with one console warning.
12
+ */
13
+ export const CLIENT_SOURCE = String.raw`// acryl-state: watch(key, onValue, onError?) follows a state key over one WebSocket for the page.
14
+ const HEARTBEAT_SILENCE_MS = 45000
15
+ const RETRY_MIN_MS = 250
16
+ const RETRY_MAX_MS = 10000
17
+
18
+ const here = import.meta.url
19
+ const watchers = new Map()
20
+ const latest = new Map()
21
+ const streams = new Map()
22
+ const complained = new Set()
23
+ let socket
24
+ let timer
25
+ let watchdog
26
+ let attempts = 0
27
+ let opened = false
28
+ let following = 'socket'
29
+ let suspended = false
30
+ let warnedStream = false
31
+
32
+ const socketUrl = () => {
33
+ const url = new URL('ws', here)
34
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
35
+ return url.href
36
+ }
37
+ const streamUrl = (key) => new URL(encodeURIComponent(key) + '/events?json=1', here).href
38
+
39
+ const deliver = (key, text) => {
40
+ if (latest.get(key) === text) return
41
+ latest.set(key, text)
42
+ let value
43
+ try {
44
+ value = JSON.parse(text)
45
+ } catch {
46
+ return
47
+ }
48
+ for (const watcher of [...(watchers.get(key) ?? [])]) {
49
+ try {
50
+ watcher.onValue(value)
51
+ } catch (cause) {
52
+ console.error('acryl-state: a watcher of ' + key + ' threw', cause)
53
+ }
54
+ }
55
+ }
56
+
57
+ const fail = (key, message) => {
58
+ const set = watchers.get(key)
59
+ if (set === undefined) return
60
+ let told = false
61
+ for (const watcher of [...set]) {
62
+ if (watcher.onError === undefined) continue
63
+ told = true
64
+ try {
65
+ watcher.onError(new Error(message))
66
+ } catch (cause) {
67
+ console.error('acryl-state: an error handler of ' + key + ' threw', cause)
68
+ }
69
+ }
70
+ if (!told && !complained.has(key)) {
71
+ complained.add(key)
72
+ console.warn('acryl-state: ' + key + ': ' + message)
73
+ }
74
+ }
75
+
76
+ const send = (frame) => {
77
+ if (socket !== undefined && socket.readyState === 1) socket.send(JSON.stringify(frame))
78
+ }
79
+
80
+ const arm = () => {
81
+ clearTimeout(watchdog)
82
+ watchdog = setTimeout(() => lost(socket), HEARTBEAT_SILENCE_MS)
83
+ }
84
+
85
+ const closeSocket = () => {
86
+ clearTimeout(watchdog)
87
+ const current = socket
88
+ socket = undefined
89
+ if (current === undefined) return
90
+ current.onopen = current.onmessage = current.onerror = current.onclose = null
91
+ try {
92
+ current.close(1000, 'page left')
93
+ } catch {
94
+ // Already closed.
95
+ }
96
+ }
97
+
98
+ const closeStreams = () => {
99
+ for (const source of streams.values()) source.close()
100
+ streams.clear()
101
+ }
102
+
103
+ const openStream = (key) => {
104
+ if (streams.has(key) || typeof EventSource === 'undefined') return
105
+ const source = new EventSource(streamUrl(key))
106
+ streams.set(key, source)
107
+ source.addEventListener('value', (event) => deliver(key, event.data))
108
+ source.onerror = () => {
109
+ // A network error is retried by the browser itself; a refusal (the key does not exist) ends the stream for good.
110
+ if (source.readyState === 2) {
111
+ streams.delete(key)
112
+ fail(key, 'the event stream was refused (no such state?)')
113
+ }
114
+ }
115
+ }
116
+
117
+ const fallBack = () => {
118
+ following = 'stream'
119
+ if (!warnedStream) {
120
+ warnedStream = true
121
+ console.warn('acryl-state: the WebSocket did not open, so each key is followed by its own event stream (each holds one of the browser\'s six connections to this host)')
122
+ }
123
+ for (const key of watchers.keys()) openStream(key)
124
+ }
125
+
126
+ // A socket that closes, errors or goes quiet: before it ever opened this is a server without WebSockets, so the streams take over; after, the server went away and the page tries again.
127
+ const lost = (which) => {
128
+ if (which !== socket) return
129
+ closeSocket()
130
+ if (watchers.size === 0 || suspended) return
131
+ if (!opened) {
132
+ fallBack()
133
+ return
134
+ }
135
+ const delay = Math.min(RETRY_MIN_MS * 2 ** attempts, RETRY_MAX_MS)
136
+ attempts += 1
137
+ clearTimeout(timer)
138
+ timer = setTimeout(connect, delay)
139
+ }
140
+
141
+ const connect = () => {
142
+ clearTimeout(timer)
143
+ if (socket !== undefined || watchers.size === 0 || suspended || following !== 'socket') return
144
+ let made
145
+ try {
146
+ made = new WebSocket(socketUrl())
147
+ } catch {
148
+ fallBack()
149
+ return
150
+ }
151
+ socket = made
152
+ made.onopen = () => {
153
+ opened = true
154
+ attempts = 0
155
+ arm()
156
+ for (const key of watchers.keys()) send({ t: 'sub', key })
157
+ }
158
+ made.onmessage = (event) => {
159
+ if (made !== socket) return
160
+ arm()
161
+ let frame
162
+ try {
163
+ frame = JSON.parse(event.data)
164
+ } catch {
165
+ return
166
+ }
167
+ if (frame === null || typeof frame !== 'object' || typeof frame.key !== 'string') return
168
+ if (typeof frame.error === 'string') fail(frame.key, frame.error)
169
+ else deliver(frame.key, JSON.stringify(frame.value))
170
+ }
171
+ made.onerror = () => lost(made)
172
+ made.onclose = () => lost(made)
173
+ }
174
+
175
+ const start = () => {
176
+ if (watchers.size === 0) return
177
+ if (following === 'stream') for (const key of watchers.keys()) openStream(key)
178
+ else connect()
179
+ }
180
+
181
+ if (typeof addEventListener === 'function') {
182
+ // A page in the back/forward cache keeps its connections open for as long as it is kept; let them go when the page is hidden for good, and open them again when it comes back.
183
+ addEventListener('pagehide', () => {
184
+ suspended = true
185
+ clearTimeout(timer)
186
+ closeSocket()
187
+ closeStreams()
188
+ })
189
+ addEventListener('pageshow', () => {
190
+ if (!suspended) return
191
+ suspended = false
192
+ start()
193
+ })
194
+ }
195
+
196
+ // Follow the state key: onValue gets the value now and after every change, as the JSON value the server holds (a string stays a string). onError, when given, is told of a key that
197
+ // does not exist; without it the page console gets one warning. Returns the function that stops watching.
198
+ export function watch(key, onValue, onError) {
199
+ if (typeof key !== 'string' || key === '') throw new TypeError('watch(key, onValue): the key is a state key like "tasks.count"')
200
+ if (typeof onValue !== 'function') throw new TypeError('watch(key, onValue): onValue is a function')
201
+ const watcher = { onValue, onError }
202
+ let set = watchers.get(key)
203
+ const first = set === undefined
204
+ if (first) {
205
+ set = new Set()
206
+ watchers.set(key, set)
207
+ }
208
+ set.add(watcher)
209
+ complained.delete(key)
210
+ if (!first && latest.has(key)) {
211
+ const text = latest.get(key)
212
+ queueMicrotask(() => {
213
+ if (!set.has(watcher)) return
214
+ try {
215
+ onValue(JSON.parse(text))
216
+ } catch (cause) {
217
+ console.error('acryl-state: a watcher of ' + key + ' threw', cause)
218
+ }
219
+ })
220
+ }
221
+ if (first && !suspended) {
222
+ if (following === 'stream') openStream(key)
223
+ else if (socket === undefined) connect()
224
+ else send({ t: 'sub', key })
225
+ }
226
+ let stopped = false
227
+ return () => {
228
+ if (stopped) return
229
+ stopped = true
230
+ set.delete(watcher)
231
+ if (set.size > 0) return
232
+ watchers.delete(key)
233
+ latest.delete(key)
234
+ complained.delete(key)
235
+ streams.get(key)?.close()
236
+ streams.delete(key)
237
+ send({ t: 'unsub', key })
238
+ if (watchers.size === 0) {
239
+ clearTimeout(timer)
240
+ closeSocket()
241
+ }
242
+ }
243
+ }
244
+ `
@@ -6,10 +6,12 @@
6
6
  */
7
7
 
8
8
  import { Context, Service } from '@deepseek-ai/cordis'
9
- import { Acryl, type Context as RequestContext } from 'acryl-router'
10
- import { streamSSE } from 'acryl-router/helpers'
9
+ import { Acryl, type Context as RequestContext, type MiddlewareHandler } from 'acryl-router'
10
+ import { streamSSE, type UpgradeWebSocket } from 'acryl-router/helpers'
11
11
  import { loopbackOnly, methodNotAllowed } from 'acryl-router/middleware'
12
12
  import { createCell, createDerived, type Cell, type ReadonlyCell } from './cell.ts'
13
+ import { CLIENT_SOURCE } from './client-source.ts'
14
+ import { refuseStranger, stateSocket } from './socket.ts'
13
15
 
14
16
  declare module '@deepseek-ai/cordis' {
15
17
  interface Context {
@@ -24,6 +26,9 @@ export const name = 'acryl-state'
24
26
 
25
27
  const KEY_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/
26
28
 
29
+ /** Names under `/api/state` that are routes, not keys: a cell cannot be called by one. */
30
+ const RESERVED_KEYS = new Set(['ws', 'client.js'])
31
+
27
32
  export class State extends Service {
28
33
  private readonly cells = new Map<string, ReadonlyCell<unknown>>()
29
34
 
@@ -33,6 +38,7 @@ export class State extends Service {
33
38
 
34
39
  private claim(key: string): void {
35
40
  if (!KEY_PATTERN.test(key)) throw new TypeError(`acryl-state: key "${key}" must be dotted lowercase words, like "market.sources"`)
41
+ if (RESERVED_KEYS.has(key)) throw new TypeError(`acryl-state: "${key}" is the name of a route under /api/state, choose another key`)
36
42
  if (this.cells.has(key)) throw new Error(`acryl-state: key "${key}" is already in use`)
37
43
  }
38
44
 
@@ -73,9 +79,10 @@ export class State extends Service {
73
79
 
74
80
  /**
75
81
  * The current value of `key` as a server-sent event stream: the value now, then one event per change, until the client cancels or the router
76
- * is disposed (both abort the stream). A comment line goes out every 15 seconds so proxies and browsers keep an idle stream open.
82
+ * is disposed (both abort the stream). A comment line goes out every 15 seconds so proxies and browsers keep an idle stream open. A string value travels as it is; with `json`
83
+ * (the `?json=1` of the browser client) every value is JSON, so `"12"` and `12` can be told apart.
77
84
  */
78
- stream(key: string, c: RequestContext): Response | undefined {
85
+ stream(key: string, c: RequestContext, json = false): Response | undefined {
79
86
  const cell = this.find(key)
80
87
  if (cell === undefined) return undefined
81
88
  return streamSSE(c, async stream => {
@@ -83,7 +90,7 @@ export class State extends Service {
83
90
  // Writes are asynchronous; chaining them keeps events in the order the cell changed.
84
91
  let queue: Promise<unknown> = Promise.resolve()
85
92
  const send = (value: unknown): void => {
86
- queue = queue.then(() => stream.writeSSE({ event: 'value', data: typeof value === 'string' ? value : JSON.stringify(value) })).catch(() => {})
93
+ queue = queue.then(() => stream.writeSSE({ event: 'value', data: typeof value === 'string' && !json ? value : JSON.stringify(value) })).catch(() => {})
87
94
  }
88
95
  send(cell.get())
89
96
  const unsubscribe = cell.subscribe(send)
@@ -105,14 +112,34 @@ export function apply(ctx: Context): void {
105
112
  // app; the terminal calls the router in process and is the host's own code.
106
113
  ctx.inject(['router'], inner => {
107
114
  const api = new Acryl()
115
+ // The WebSocket form is mounted when a server that upgrades appears (the server row comes after this one in a blend); until then, and without the `ws` package, `/ws` answers 404 and the
116
+ // browser client follows the event streams. The route is registered before `/:key`, which would otherwise take `ws` for a key.
117
+ let socket: MiddlewareHandler | undefined
108
118
  api.use(loopbackOnly({ inProcess: 'allow' }), methodNotAllowed({ app: api }))
109
119
  .get('/', c => c.json({ keys: state.keys() }))
120
+ .get('/client.js', c => c.body(CLIENT_SOURCE, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }))
121
+ .get('/ws', async (c, next) => {
122
+ const upgrade = socket
123
+ if (upgrade === undefined) return c.json({ error: 'this server does not upgrade to a WebSocket' }, 404)
124
+ const refusal = refuseStranger(c)
125
+ if (refusal !== undefined) return refusal
126
+ return await upgrade(c, next)
127
+ })
110
128
  .get('/:key', c => {
111
129
  const cell = state.find(c.req.param('key'))
112
130
  return cell === undefined ? c.json({ error: 'no such state' }, 404) : c.json({ key: cell.key, value: cell.get() })
113
131
  })
114
- .get('/:key/events', c => state.stream(c.req.param('key'), c) ?? c.json({ error: 'no such state' }, 404))
132
+ .get('/:key/events', c => state.stream(c.req.param('key'), c, c.req.query('json') === '1') ?? c.json({ error: 'no such state' }, 404))
115
133
  inner.router.route('/api/state', api)
134
+ inner.inject(['nodeServer'], withServer => {
135
+ try {
136
+ const upgrade = (withServer as unknown as { nodeServer: { upgrades(): UpgradeWebSocket } }).nodeServer.upgrades()
137
+ socket = stateSocket(state, { upgrade })
138
+ withServer.effect(() => () => { socket = undefined }, 'acryl-state: the WebSocket route')
139
+ } catch (cause) {
140
+ ctx.logger.warn(`acryl-state: state is followed by event streams only (${cause instanceof Error ? cause.message : String(cause)})`)
141
+ }
142
+ })
116
143
  // Over HTTP only where an adapter exists (Web and Desktop); the terminal needs none.
117
144
  inner.inject(['routerHttp'], http => { http.routerHttp.mount('/api/state') })
118
145
  })
@@ -0,0 +1,96 @@
1
+ /**
2
+ * State over a WebSocket. A server-sent event stream holds one of the browser's six connections per host for as long as the page lives, and an app that follows a few keys in
3
+ * several windows uses them all; a WebSocket is not one of those six, and one socket carries every key of a page.
4
+ *
5
+ * `GET /api/state/ws` upgrades. Up, one JSON message per frame: `{ "t": "sub", "key": "<key>" }` and `{ "t": "unsub", "key": "<key>" }`. Down: `{ "key": "<key>", "value": <json> }` once on
6
+ * `sub` and after every change, `{ "key": "<key>", "error": "<why>" }` for a key that cannot be followed, and `{ "t": "ping" }` every {@link SOCKET_HEARTBEAT_MS} so a page can tell a quiet
7
+ * socket from a dead one. `GET /api/state/client.js` is the browser module that speaks this (`watch(key, callback)`), with the event stream as its fallback.
8
+ *
9
+ * A WebSocket has no CORS, so the upgrade is checked here ({@link refuseStranger}): a loopback peer on this port with a loopback `Host`, and an `Origin` that, when the request has one,
10
+ * is exactly this app's own origin. A program on this machine that sends no `Origin` (a script, a test) is let in, as it is for the JSON and stream routes; a web page of another
11
+ * origin, and a name that only resolves to this machine (DNS rebinding), are not.
12
+ */
13
+
14
+ import type { Context, MiddlewareHandler } from 'acryl-router'
15
+ import type { UpgradeWebSocket } from 'acryl-router/helpers'
16
+ import { loopbackAuthority } from 'acryl-router/middleware'
17
+ import type { ReadonlyCell } from './cell.ts'
18
+
19
+ /** How often the server writes a `ping`; a page gives up on a socket that is silent for three of these. */
20
+ export const SOCKET_HEARTBEAT_MS = 15_000
21
+
22
+ /** How many keys one socket may follow; a page watches a handful. */
23
+ export const MAX_KEYS_PER_SOCKET = 256
24
+
25
+ /** What the socket needs of the state service. */
26
+ export interface Cells {
27
+ find(key: string): ReadonlyCell<unknown> | undefined
28
+ }
29
+
30
+ export interface StateSocketOptions {
31
+ readonly upgrade: UpgradeWebSocket
32
+ readonly heartbeatMs?: number
33
+ }
34
+
35
+ /** A refusal (403) unless the upgrade comes from this machine to this app, and from its own page when it comes from a page at all. */
36
+ export function refuseStranger(c: Context): Response | undefined {
37
+ const authority = loopbackAuthority(c)
38
+ if (authority === undefined) return c.json({ error: 'request authority rejected' }, 403)
39
+ const origin = c.req.header('origin')
40
+ const site = c.req.header('sec-fetch-site')
41
+ if ((origin !== undefined && origin !== authority.origin) || (site !== undefined && site !== 'same-origin')) {
42
+ return c.json({ error: 'only this app\'s own page may follow its state' }, 403)
43
+ }
44
+ return undefined
45
+ }
46
+
47
+ /** The route handler that upgrades and follows keys of `cells` for as long as the socket is open. */
48
+ export function stateSocket(cells: Cells, options: StateSocketOptions): MiddlewareHandler {
49
+ const heartbeatMs = options.heartbeatMs ?? SOCKET_HEARTBEAT_MS
50
+ return options.upgrade(() => {
51
+ const following = new Map<string, () => void>()
52
+ let beat: NodeJS.Timeout | undefined
53
+ return {
54
+ onOpen(_event, ws) {
55
+ beat = setInterval(() => {
56
+ try { ws.send(JSON.stringify({ t: 'ping' })) } catch { /* the close event follows */ }
57
+ }, heartbeatMs)
58
+ beat.unref()
59
+ },
60
+ onMessage(event, ws) {
61
+ if (typeof event.data !== 'string') return
62
+ let message: { t?: unknown, key?: unknown }
63
+ try { message = JSON.parse(event.data) as typeof message } catch { return }
64
+ if (typeof message.key !== 'string') return
65
+ const key = message.key
66
+ const tell = (frame: object): void => {
67
+ try { ws.send(JSON.stringify(frame)) } catch { /* the close event follows */ }
68
+ }
69
+ const value = (current: unknown): void => {
70
+ let text: string
71
+ try { text = JSON.stringify({ key, value: current === undefined ? null : current }) } catch { tell({ key, error: 'the value cannot be sent as JSON' }); return }
72
+ try { ws.send(text) } catch { /* the close event follows */ }
73
+ }
74
+ if (message.t === 'unsub') {
75
+ following.get(key)?.()
76
+ following.delete(key)
77
+ return
78
+ }
79
+ if (message.t !== 'sub') return
80
+ const cell = cells.find(key)
81
+ if (cell === undefined) { tell({ key, error: 'no such state' }); return }
82
+ const known = following.has(key)
83
+ if (!known && following.size >= MAX_KEYS_PER_SOCKET) { tell({ key, error: 'too many keys on one socket' }); return }
84
+ // A key asked for twice is answered again with its value, and followed once.
85
+ if (known) { value(cell.get()); return }
86
+ value(cell.get())
87
+ following.set(key, cell.subscribe(value))
88
+ },
89
+ onClose() {
90
+ if (beat !== undefined) clearInterval(beat)
91
+ for (const stop of following.values()) stop()
92
+ following.clear()
93
+ },
94
+ }
95
+ }, { textOnly: true })
96
+ }
@@ -42,70 +42,90 @@ interface RunSource {
42
42
  readonly events: AsyncIterable<PanelEvent>
43
43
  }
44
44
 
45
+ /** The door's id for this page, when the door is in it: sent with a question so the agent's calls (which name no window) drive this page and not another open one. */
46
+ function doorPage(): { page?: string } {
47
+ const published = (window as unknown as Record<symbol, { windowId?: unknown } | undefined>)[
48
+ Symbol.for('acryl.door.page')
49
+ ]
50
+ return typeof published?.windowId === 'string' ? { page: published.windowId } : {}
51
+ }
52
+
45
53
  /** A WebSocket is not one of the browser's six connections per host, so the panel's Stop and a run still work when two windows stream their own state. */
46
54
  let socketBroken = false
47
55
  let activeSocket: WebSocket | undefined
48
56
 
49
- /** Open the run socket and send the token and the first action. Rejects when the page cannot upgrade (a proxy, a server without `ws`): the caller falls back to the stream. */
50
- function openSocket(token: string, action: Record<string, unknown>): Promise<WebSocket> {
57
+ /** The server did not accept the token: it is not the one this page was given (the app restarted since, which `node --watch` does at every change of a file). */
58
+ class Refused extends Error {}
59
+
60
+ /**
61
+ * Open the run socket and send the token and the first action. Resolves once the server has accepted the token (its `accepted` frame), with the socket and its events: the
62
+ * frames of the run as panel events, until the socket closes. One set of handlers serves the socket for its whole life, so a frame is never handed to a handler that was
63
+ * replaced. Rejects with `Refused` when the token is not accepted, and with another error when the page cannot upgrade (a proxy, a server without `ws`): the caller falls back
64
+ * to the stream.
65
+ */
66
+ function openSocket(
67
+ token: string,
68
+ action: Record<string, unknown>,
69
+ ): Promise<{ socket: WebSocket; events: AsyncGenerator<PanelEvent> }> {
51
70
  return new Promise((resolve, reject) => {
52
71
  const socket = new WebSocket(
53
72
  `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${PREFIX}/run/ws`,
54
73
  )
74
+ const queue: PanelEvent[] = []
75
+ let wake: (() => void) | undefined
76
+ let closed = false
77
+ let accepted = false
55
78
  const timer = setTimeout(() => {
56
79
  socket.close()
57
- reject(new Error('the socket did not open in time'))
80
+ reject(new Error('the socket was not accepted in time'))
58
81
  }, 4000)
59
82
  socket.onopen = () => {
60
- clearTimeout(timer)
61
83
  socket.send(JSON.stringify({ t: 'hello', token }))
62
84
  socket.send(JSON.stringify(action))
63
- resolve(socket)
64
85
  }
65
- socket.onerror = () => {
66
- clearTimeout(timer)
67
- reject(new Error('the server refused the upgrade'))
86
+ socket.onmessage = (message) => {
87
+ try {
88
+ const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
89
+ if (frame.e === 'accepted' && !accepted) {
90
+ accepted = true
91
+ clearTimeout(timer)
92
+ resolve({ socket, events: read() })
93
+ } else if (typeof frame.e === 'string')
94
+ queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
95
+ } catch {
96
+ // Not a frame of ours.
97
+ }
98
+ wake?.()
68
99
  }
69
- })
70
- }
71
-
72
- /** The frames of an open run socket as panel events, until it closes. */
73
- async function* socketEvents(socket: WebSocket): AsyncGenerator<PanelEvent> {
74
- const queue: PanelEvent[] = []
75
- let wake: (() => void) | undefined
76
- let closed: CloseEvent | undefined
77
- socket.onmessage = (message) => {
78
- try {
79
- const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
80
- if (typeof frame.e === 'string')
81
- queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
82
- } catch {
83
- // Not a frame of ours.
100
+ socket.onclose = (event) => {
101
+ clearTimeout(timer)
102
+ closed = true
103
+ if (!accepted)
104
+ reject(
105
+ event.code === 4401
106
+ ? new Refused('the token was not accepted')
107
+ : new Error('the socket closed before it was accepted'),
108
+ )
109
+ wake?.()
84
110
  }
85
- wake?.()
86
- }
87
- socket.onclose = (event) => {
88
- closed = event
89
- wake?.()
90
- }
91
- for (;;) {
92
- const next = queue.shift()
93
- if (next !== undefined) {
94
- yield next
95
- continue
111
+ socket.onerror = () => {
112
+ clearTimeout(timer)
113
+ if (!accepted) reject(new Error('the server refused the upgrade'))
96
114
  }
97
- if (closed !== undefined) {
98
- if (closed.code === 4401)
99
- yield {
100
- event: 'error',
101
- data: { message: 'the panel was not accepted (reload the page and try again)' },
115
+ async function* read(): AsyncGenerator<PanelEvent> {
116
+ for (;;) {
117
+ const next = queue.shift()
118
+ if (next !== undefined) {
119
+ yield next
120
+ continue
102
121
  }
103
- return
122
+ if (closed) return
123
+ await new Promise<void>((done) => {
124
+ wake = done
125
+ })
126
+ }
104
127
  }
105
- await new Promise<void>((resolve) => {
106
- wake = resolve
107
- })
108
- }
128
+ })
109
129
  }
110
130
 
111
131
  const css = `
@@ -145,7 +165,7 @@ textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border
145
165
  async function start(): Promise<void> {
146
166
  const reply = await fetch(`${PREFIX}/session`)
147
167
  if (!reply.ok) return
148
- const session = (await reply.json()) as Session
168
+ let session = (await reply.json()) as Session
149
169
 
150
170
  const host = document.createElement('div')
151
171
  host.id = 'agent-panel'
@@ -199,6 +219,7 @@ async function start(): Promise<void> {
199
219
  onChange(change: { extension: string; action: string }): void
200
220
  }): Promise<{ detach(): void; stop(): Promise<void> }>
201
221
  }
222
+ await refreshSession()
202
223
  terminal = await openTerminal({
203
224
  container: term,
204
225
  token: session.token,
@@ -265,26 +286,41 @@ async function start(): Promise<void> {
265
286
  // A run belongs to the app, not to this page: it goes on when the page reloads (the door reloads it after a plugin change, an agent may move it to another page) and the page that
266
287
  // loads next follows it again. Only Stop cancels it.
267
288
  const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
289
+ /**
290
+ * The token belongs to one start of the app: `node --watch` restarts it at every change of a file, and a page left open across that restart holds a token the new server
291
+ * does not know. Ask for the session again; the caller tries once more with what it gets.
292
+ */
293
+ const refreshSession = async (): Promise<void> => {
294
+ const reply = await fetch(`${PREFIX}/session`)
295
+ if (!reply.ok) return
296
+ session = (await reply.json()) as Session
297
+ headers['x-agent-panel-token'] = session.token
298
+ }
268
299
  let following = false
269
- stop.addEventListener('click', () => {
300
+ const stopRun = (): void => {
270
301
  // On the run's socket when there is one: a request would wait behind the browser's six connections per host.
271
302
  if (activeSocket?.readyState === WebSocket.OPEN)
272
303
  activeSocket.send(JSON.stringify({ t: 'stop' }))
273
304
  else void fetch(`${PREFIX}/stop`, { method: 'POST', headers })
274
- })
275
- /** A run over the socket when the server upgrades, else over the stream, with one console warning saying why. */
276
- const openRun = async (
277
- action: { t: 'ask'; message: string; driver: string } | { t: 'follow' },
278
- ): Promise<RunSource | { error: string } | undefined> => {
305
+ }
306
+ stop.addEventListener('click', stopRun)
307
+ // The door's "Agent is driving" bar has a Stop too (the same page): it ends this run.
308
+ window.addEventListener('acryl:stop-run', stopRun)
309
+ const REFUSED = Symbol('refused')
310
+ /** A run over the socket when the server upgrades, else over the stream, with one console warning saying why. `REFUSED` when the server does not know the token. */
311
+ const tryOpenRun = async (
312
+ action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
313
+ ): Promise<RunSource | { error: string } | undefined | typeof REFUSED> => {
279
314
  if (!socketBroken && typeof WebSocket !== 'undefined') {
280
315
  try {
281
- const socket = await openSocket(session.token, action)
316
+ const { socket, events: frames } = await openSocket(session.token, action)
282
317
  activeSocket = socket
283
318
  socket.addEventListener('close', () => {
284
319
  if (activeSocket === socket) activeSocket = undefined
285
320
  })
286
- return { events: socketEvents(socket) }
321
+ return { events: frames }
287
322
  } catch (cause) {
323
+ if (cause instanceof Refused) return REFUSED
288
324
  socketBroken = true
289
325
  console.warn(
290
326
  `agent-panel: the run could not open a WebSocket (${cause instanceof Error ? cause.message : String(cause)}); using server-sent events, which count against the browser's six connections per host`,
@@ -295,8 +331,9 @@ async function start(): Promise<void> {
295
331
  const response = await fetch(`${PREFIX}/ask`, {
296
332
  method: 'POST',
297
333
  headers,
298
- body: JSON.stringify({ message: action.message, driver: action.driver }),
334
+ body: JSON.stringify({ message: action.message, driver: action.driver, page: action.page }),
299
335
  })
336
+ if (response.status === 403) return REFUSED
300
337
  if (!response.ok)
301
338
  return {
302
339
  error:
@@ -306,10 +343,23 @@ async function start(): Promise<void> {
306
343
  return { events: events(response) }
307
344
  }
308
345
  const latest = await fetch(`${PREFIX}/run`, { headers })
346
+ if (latest.status === 403) return REFUSED
309
347
  return latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream')
310
348
  ? { events: events(latest) }
311
349
  : undefined
312
350
  }
351
+ /** `tryOpenRun`, and when the token was refused, a new session and one more try without the person seeing anything; a second refusal is shown. */
352
+ const openRun = async (
353
+ action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
354
+ ): Promise<RunSource | { error: string } | undefined> => {
355
+ const first = await tryOpenRun(action)
356
+ if (first !== REFUSED) return first
357
+ await refreshSession()
358
+ const second = await tryOpenRun(action)
359
+ return second === REFUSED
360
+ ? { error: 'the panel was not accepted (reload the page and try again)' }
361
+ : second
362
+ }
313
363
  /**
314
364
  * Show one run, as its events come, until it ends. `own` is true for a run this page just started (its question is on screen); a run found on load is shown from its question, and
315
365
  * a run that had already ended before the page loaded is only shown, never reloads the page.
@@ -400,7 +450,7 @@ async function start(): Promise<void> {
400
450
  input.value = ''
401
451
  line(message, 'me')
402
452
  try {
403
- const run = await openRun({ t: 'ask', message, driver: driver.value })
453
+ const run = await openRun({ t: 'ask', message, driver: driver.value, ...doorPage() })
404
454
  if (run === undefined) return
405
455
  if ('error' in run) {
406
456
  line(run.error, 'err')