acrylblends 1.0.7 → 1.0.9

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.
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { UiControlError, parseChannelToPage, type ChannelToHost } from './protocol.ts'
11
11
  import type { UiDriver } from './driver/driver.ts'
12
+ import { type SocketLike, type SocketSource, socketSource } from './socket-source.ts'
12
13
 
13
14
  /** The slice of an `EventSource` the channel uses, so tests can supply their own. */
14
15
  export interface ChannelSource {
@@ -32,6 +33,11 @@ export interface PageChannelOptions {
32
33
  /** Where the door's routes are, e.g. `/api/acryl-door`. */
33
34
  readonly base: string
34
35
  readonly createSource?: (url: string) => ChannelSource
36
+ /** Opens the WebSocket form of the channel (`<base>/channel/ws`); the browser's own by default. Pass `transport: 'stream'` to keep to the event stream. */
37
+ readonly createSocket?: (url: string) => SocketSource
38
+ readonly transport?: 'auto' | 'stream'
39
+ /** Where the page says it fell back to the event stream (the console by default). */
40
+ readonly warn?: (message: string) => void
35
41
  readonly post?: (url: string, body: string) => Promise<unknown>
36
42
  readonly silenceMs?: number
37
43
  readonly now?: () => number
@@ -42,6 +48,9 @@ export class PageChannel {
42
48
  private reconnectTimer: ReturnType<typeof setTimeout> | undefined
43
49
  private watchdog: ReturnType<typeof setInterval> | undefined
44
50
  private backoff = BACKOFF_START_MS
51
+ /** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
52
+ private socketAllowed: boolean
53
+ private socketReady = false
45
54
  private lastHeard = 0
46
55
  private disposed = false
47
56
  private readonly now: () => number
@@ -53,33 +62,56 @@ export class PageChannel {
53
62
  this.driver = driver
54
63
  this.options = options
55
64
  this.now = options.now ?? (() => Date.now())
65
+ // A test that supplies only a stream keeps to it; the browser's own WebSocket is tried when the page supplies neither.
66
+ this.socketAllowed = options.transport !== 'stream' && (options.createSocket !== undefined || (options.createSource === undefined && typeof WebSocket !== 'undefined'))
56
67
  }
57
68
 
58
69
  connect(): void {
59
70
  if (this.disposed || this.source !== undefined) return
60
- const create = this.options.createSource ?? ((url: string): ChannelSource => new EventSource(url))
61
71
  const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}${this.options.shellWindow === undefined ? '' : `&shell=${String(this.options.shellWindow)}`}`
62
- const source = create(`${this.options.base}/channel?${query}`)
72
+ const viaSocket = this.socketAllowed
73
+ this.socketReady = false
74
+ const source: ChannelSource = viaSocket
75
+ ? (this.options.createSocket ?? ((url: string): SocketSource => socketSource(new WebSocket(url) as unknown as SocketLike)))(`${socketBase(this.options.base)}/channel/ws?${query}`)
76
+ : (this.options.createSource ?? ((url: string): ChannelSource => new EventSource(url) as unknown as ChannelSource))(`${this.options.base}/channel?${query}`)
63
77
  this.source = source
64
78
  this.lastHeard = this.now()
65
79
  const heard = (): void => {
66
80
  this.lastHeard = this.now()
67
81
  this.backoff = BACKOFF_START_MS
68
82
  }
69
- source.addEventListener('ready', heard)
83
+ source.addEventListener('ready', () => {
84
+ this.socketReady = true
85
+ heard()
86
+ })
70
87
  source.addEventListener('ping', heard)
71
88
  source.addEventListener('call', (event) => {
72
89
  heard()
73
90
  if (typeof event.data === 'string') this.answer(event.data)
74
91
  })
75
- // An EventSource retries by itself after a network error; once it gives up (CLOSED), the channel starts over.
76
- source.onerror = () => { if (source.readyState === CLOSED) this.restart(source) }
92
+ // An EventSource retries by itself after a network error; once it gives up (CLOSED), the channel starts over. A WebSocket that never became ready is not retried: the page
93
+ // falls back to the event stream, and says so once.
94
+ source.onerror = () => {
95
+ if (source.readyState !== CLOSED) return
96
+ if (viaSocket && !this.socketReady) this.fallBack(source)
97
+ else this.restart(source)
98
+ }
77
99
  const silence = this.options.silenceMs ?? SILENCE_MS
78
100
  this.watchdog = setInterval(() => {
79
101
  if (this.now() - this.lastHeard > silence) this.restart(source)
80
102
  }, Math.max(50, Math.floor(silence / 3)))
81
103
  }
82
104
 
105
+ /** The WebSocket did not open: use the event stream from now on, and say why once. */
106
+ private fallBack(source: ChannelSource): void {
107
+ if (this.source !== source) return
108
+ this.socketAllowed = false
109
+ const warn = this.options.warn ?? ((message: string) => { console.warn(message) })
110
+ warn('acryl-door: the page channel could not open a WebSocket (the server has no upgrade route, or a proxy refused it); using server-sent events, which count against the browser\'s six connections per host')
111
+ this.stop()
112
+ this.connect()
113
+ }
114
+
83
115
  /** Tell the Host whether this window is the one in front. */
84
116
  reportFocus(focused: boolean): void {
85
117
  this.send({ t: 'focus', focused })
@@ -149,9 +181,19 @@ export class PageChannel {
149
181
  }
150
182
 
151
183
  private send(message: ChannelToHost): void {
184
+ // On a WebSocket the answer goes up the socket: a request would queue behind the browser's six connections per host.
185
+ const source = this.source as Partial<SocketSource> | undefined
186
+ if (source?.send?.(JSON.stringify(message)) === true) return
152
187
  const post = this.options.post ?? ((url: string, body: string) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
153
188
  post(`${this.options.base}/channel/answer`, JSON.stringify({ window: this.options.windowId, message })).catch(() => {
154
189
  // The Host is gone or restarting; the call it made settles on its side.
155
190
  })
156
191
  }
157
192
  }
193
+
194
+ /** The WebSocket address of the door's routes: this page's own origin, `ws:` or `wss:`, then the base path. */
195
+ function socketBase(base: string): string {
196
+ const here = (globalThis as { location?: { protocol: string; host: string } }).location
197
+ if (here === undefined) return base
198
+ return `${here.protocol === 'https:' ? 'wss' : 'ws'}://${here.host}${base}`
199
+ }
@@ -22,7 +22,15 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
22
22
  const detachInput = clock.attach(document)
23
23
  const removeIndicator = mountIndicator(driver, document)
24
24
  const shellWindow = Number(/\bAcrylWindow\/(\d{1,9})\b/u.exec(view.navigator?.userAgent ?? '')?.[1])
25
- const channel = new PageChannel(driver, { windowId, focused, base, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
25
+ // `localStorage.setItem('acryl-door-transport', 'stream')` keeps this browser to the event stream (to compare, or behind a proxy that cannot upgrade).
26
+ const transport = ((): 'auto' | 'stream' => {
27
+ try {
28
+ return view.localStorage.getItem('acryl-door-transport') === 'stream' ? 'stream' : 'auto'
29
+ } catch {
30
+ return 'auto'
31
+ }
32
+ })()
33
+ const channel = new PageChannel(driver, { windowId, focused, base, transport, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
26
34
  const reportFocus = (): void => { channel.reportFocus(focused()) }
27
35
  view.addEventListener('focus', reportFocus)
28
36
  view.addEventListener('blur', reportFocus)
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A WebSocket that looks like the event stream the channel already runs on: `addEventListener('call' | 'ready' | 'ping')`, `onerror`, `close()` and `readyState` (0 connecting, 1 open,
3
+ * 2 closed). Each frame from the Host is `{ "e": <event>, "d": <data> }`. It can also send, so the page's answers go up the same socket and need no request of their own: a request
4
+ * would wait behind the browser's six connections per host, which is what the socket is for.
5
+ */
6
+
7
+ import type { ChannelSource } from './channel.ts'
8
+
9
+ /** The slice of the browser's `WebSocket` used here, so a test can supply its own. */
10
+ export interface SocketLike {
11
+ readyState: number
12
+ onopen: ((event: Event) => void) | null
13
+ onmessage: ((event: MessageEvent) => void) | null
14
+ onerror: ((event: Event) => void) | null
15
+ onclose: ((event: CloseEvent) => void) | null
16
+ send(data: string): void
17
+ close(code?: number, reason?: string): void
18
+ }
19
+
20
+ export interface SocketSource extends ChannelSource {
21
+ /** Send one message up; false when the socket is not open (the caller posts it instead). */
22
+ send(text: string): boolean
23
+ }
24
+
25
+ export function socketSource(socket: SocketLike): SocketSource {
26
+ const listeners = new Map<string, Array<(event: MessageEvent) => void>>()
27
+ let closed = false
28
+ const source: SocketSource = {
29
+ onerror: null,
30
+ get readyState(): number {
31
+ return closed || socket.readyState >= 2 ? 2 : socket.readyState
32
+ },
33
+ addEventListener(type, listener) {
34
+ listeners.set(type, [...(listeners.get(type) ?? []), listener])
35
+ },
36
+ send(text) {
37
+ if (socket.readyState !== 1) return false
38
+ try {
39
+ socket.send(text)
40
+ return true
41
+ } catch {
42
+ return false
43
+ }
44
+ },
45
+ close() {
46
+ closed = true
47
+ socket.onmessage = null
48
+ socket.onerror = null
49
+ socket.onclose = null
50
+ try {
51
+ socket.close(1000, 'page left')
52
+ } catch {
53
+ // Already closed.
54
+ }
55
+ },
56
+ }
57
+ socket.onmessage = (event) => {
58
+ if (typeof event.data !== 'string') return
59
+ let frame: { e?: unknown; d?: unknown }
60
+ try {
61
+ frame = JSON.parse(event.data) as { e?: unknown; d?: unknown }
62
+ } catch {
63
+ return
64
+ }
65
+ if (typeof frame.e !== 'string') return
66
+ for (const listener of listeners.get(frame.e) ?? []) listener({ data: typeof frame.d === 'string' ? frame.d : '' } as MessageEvent)
67
+ }
68
+ // An error or a close that this side did not ask for is the channel's cue to start over, or to fall back to the event stream.
69
+ const lost = (event: Event): void => {
70
+ if (!closed) source.onerror?.(event)
71
+ }
72
+ socket.onerror = lost
73
+ socket.onclose = lost
74
+ return source
75
+ }
@@ -27,6 +27,9 @@ export interface ShellWindow {
27
27
  readonly focused: boolean
28
28
  readonly width: number
29
29
  readonly height: number
30
+ /** Where the window's top left corner is on the screen (so two windows on top of each other can be told). */
31
+ readonly x?: number
32
+ readonly y?: number
30
33
  }
31
34
 
32
35
  export interface ShellMenuItem {
@@ -37,6 +37,77 @@ async function* events(response: Response): AsyncGenerator<PanelEvent> {
37
37
  }
38
38
  }
39
39
 
40
+ /** What a run is read from: its events, and a way to end the run. A socket and a stream give the same events. */
41
+ interface RunSource {
42
+ readonly events: AsyncIterable<PanelEvent>
43
+ }
44
+
45
+ /** 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
+ let socketBroken = false
47
+ let activeSocket: WebSocket | undefined
48
+
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> {
51
+ return new Promise((resolve, reject) => {
52
+ const socket = new WebSocket(
53
+ `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${PREFIX}/run/ws`,
54
+ )
55
+ const timer = setTimeout(() => {
56
+ socket.close()
57
+ reject(new Error('the socket did not open in time'))
58
+ }, 4000)
59
+ socket.onopen = () => {
60
+ clearTimeout(timer)
61
+ socket.send(JSON.stringify({ t: 'hello', token }))
62
+ socket.send(JSON.stringify(action))
63
+ resolve(socket)
64
+ }
65
+ socket.onerror = () => {
66
+ clearTimeout(timer)
67
+ reject(new Error('the server refused the upgrade'))
68
+ }
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.
84
+ }
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
96
+ }
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)' },
102
+ }
103
+ return
104
+ }
105
+ await new Promise<void>((resolve) => {
106
+ wake = resolve
107
+ })
108
+ }
109
+ }
110
+
40
111
  const css = `
41
112
  :host { all: initial }
42
113
  * { box-sizing: border-box; font: 14px system-ui, sans-serif }
@@ -68,6 +139,7 @@ textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border
68
139
  .row { display: flex; gap: 8px; align-items: center }
69
140
  .row button, header button { padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer }
70
141
  .row button.send { background: #1f2937; color: #fff; border-color: #1f2937 }
142
+ .row button:disabled { opacity: 0.45; cursor: not-allowed }
71
143
  `
72
144
 
73
145
  async function start(): Promise<void> {
@@ -194,32 +266,88 @@ async function start(): Promise<void> {
194
266
  // loads next follows it again. Only Stop cancels it.
195
267
  const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
196
268
  let following = false
197
- stop.addEventListener('click', () => void fetch(`${PREFIX}/stop`, { method: 'POST', headers }))
269
+ stop.addEventListener('click', () => {
270
+ // On the run's socket when there is one: a request would wait behind the browser's six connections per host.
271
+ if (activeSocket?.readyState === WebSocket.OPEN)
272
+ activeSocket.send(JSON.stringify({ t: 'stop' }))
273
+ 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> => {
279
+ if (!socketBroken && typeof WebSocket !== 'undefined') {
280
+ try {
281
+ const socket = await openSocket(session.token, action)
282
+ activeSocket = socket
283
+ socket.addEventListener('close', () => {
284
+ if (activeSocket === socket) activeSocket = undefined
285
+ })
286
+ return { events: socketEvents(socket) }
287
+ } catch (cause) {
288
+ socketBroken = true
289
+ console.warn(
290
+ `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`,
291
+ )
292
+ }
293
+ }
294
+ if (action.t === 'ask') {
295
+ const response = await fetch(`${PREFIX}/ask`, {
296
+ method: 'POST',
297
+ headers,
298
+ body: JSON.stringify({ message: action.message, driver: action.driver }),
299
+ })
300
+ if (!response.ok)
301
+ return {
302
+ error:
303
+ ((await response.json().catch(() => ({}))) as { error?: string }).error ??
304
+ `The panel answered ${response.status}.`,
305
+ }
306
+ return { events: events(response) }
307
+ }
308
+ const latest = await fetch(`${PREFIX}/run`, { headers })
309
+ return latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream')
310
+ ? { events: events(latest) }
311
+ : undefined
312
+ }
198
313
  /**
199
314
  * 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
200
315
  * a run that had already ended before the page loaded is only shown, never reloads the page.
201
316
  */
202
- const watch = async (response: Response, own: boolean): Promise<void> => {
317
+ const watch = async (source: RunSource, own: boolean): Promise<void> => {
203
318
  let live = true
204
- following = true
205
- send.disabled = true
206
- stop.disabled = false
207
319
  let changed = false
208
320
  let answer: HTMLElement | undefined
209
321
  let answered = ''
210
- // A coding agent started headless says little until it is done, so the panel says it is working, and for how long.
211
- const started = Date.now()
212
- const working = line('', 'working')
322
+ // A coding agent started headless says little until it is done, so the panel says it is working, and for how long (from when the run began, not from when this page loaded).
323
+ let started = Date.now()
324
+ let working: HTMLElement | undefined
325
+ let timer: ReturnType<typeof setInterval> | undefined
213
326
  const tick = (): void => {
214
- const seconds = Math.floor((Date.now() - started) / 1000)
327
+ if (working === undefined) return
328
+ const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000))
215
329
  working.textContent = `Working... ${String(Math.floor(seconds / 60))}:${String(seconds % 60).padStart(2, '0')}`
216
330
  log.append(working)
217
331
  }
218
- tick()
219
- const timer = setInterval(tick, 1000)
332
+ // The panel is busy once there is a run to show. A page that just loaded asks the server for the latest run first, and "there is none" must not make a message typed in that moment
333
+ // wait or vanish, so a run that is only being looked for does not count (it begins at its first event).
334
+ const begin = (): void => {
335
+ if (working !== undefined) return
336
+ following = true
337
+ send.disabled = true
338
+ stop.disabled = false
339
+ working = line('', 'working')
340
+ tick()
341
+ timer = setInterval(tick, 1000)
342
+ }
343
+ if (own) begin()
220
344
  try {
221
- for await (const { event, data } of events(response)) {
345
+ for await (const { event, data } of source.events) {
346
+ if (event === 'idle') continue
347
+ begin()
222
348
  if (event === 'run') {
349
+ if (typeof data.startedAt === 'number') started = data.startedAt
350
+ tick()
223
351
  live = data.ended !== true
224
352
  if (!own) {
225
353
  if (typeof data.message === 'string') line(data.message, 'me')
@@ -250,11 +378,13 @@ async function start(): Promise<void> {
250
378
  } catch (error) {
251
379
  line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
252
380
  } finally {
253
- clearInterval(timer)
254
- working.remove()
255
- following = false
256
- send.disabled = false
257
- stop.disabled = true
381
+ if (timer !== undefined) clearInterval(timer)
382
+ if (working !== undefined) {
383
+ working.remove()
384
+ following = false
385
+ send.disabled = false
386
+ stop.disabled = true
387
+ }
258
388
  }
259
389
  // A plugin was installed or removed while this page watched: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
260
390
  // A run that ended before this page loaded was already shown after its reload, and does not reload it again.
@@ -270,29 +400,21 @@ async function start(): Promise<void> {
270
400
  input.value = ''
271
401
  line(message, 'me')
272
402
  try {
273
- const response = await fetch(`${PREFIX}/ask`, {
274
- method: 'POST',
275
- headers,
276
- body: JSON.stringify({ message, driver: driver.value }),
277
- })
278
- if (!response.ok) {
279
- line(
280
- ((await response.json().catch(() => ({}))) as { error?: string }).error ??
281
- `The panel answered ${response.status}.`,
282
- 'err',
283
- )
403
+ const run = await openRun({ t: 'ask', message, driver: driver.value })
404
+ if (run === undefined) return
405
+ if ('error' in run) {
406
+ line(run.error, 'err')
284
407
  return
285
408
  }
286
- await watch(response, true)
409
+ await watch(run, true)
287
410
  } catch (error) {
288
411
  line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
289
412
  }
290
413
  })
291
414
  // The latest run, if there is one: shown again after a reload (its question, its events), and followed if it is still going.
292
415
  try {
293
- const latest = await fetch(`${PREFIX}/run`, { headers })
294
- if (latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream'))
295
- void watch(latest, false)
416
+ const latest = await openRun({ t: 'follow' })
417
+ if (latest !== undefined && !('error' in latest)) void watch(latest, false)
296
418
  } catch {
297
419
  // No run to show.
298
420
  }
@@ -195,3 +195,23 @@ export function headlessDriver(name: HeadlessName, root: string): ChatDriver {
195
195
  },
196
196
  }
197
197
  }
198
+
199
+ /**
200
+ * Why a driver name cannot be used to ask a question, in a sentence: it is a terminal (picked in the panel, not asked over `/ask`), it is a coding agent that is not installed or not
201
+ * able to start, or pi without a key to reach its model. Nothing when the name is not one of the headless agents.
202
+ */
203
+ export function whyNotChat(
204
+ name: string,
205
+ kind: 'chat' | 'terminal' | undefined,
206
+ ): string | undefined {
207
+ if (kind === 'terminal')
208
+ return `"${name}" is a terminal driver: pick it in the panel to open its terminal; /ask runs the chat drivers`
209
+ if (name !== 'claude' && name !== 'codex' && name !== 'pi') return undefined
210
+ if (!onPath(name))
211
+ return `${name} was not found on the PATH of the app, so the panel cannot start it`
212
+ const launch = resolveLaunch(name, [])
213
+ if ('error' in launch) return launch.error
214
+ if (name === 'pi' && !piHasDeepseekKey())
215
+ return 'pi needs a DeepSeek key: set DEEPSEEK_API_KEY in the environment of the app, or log in with pi once (it keeps the key in ~/.pi/agent/auth.json)'
216
+ return undefined
217
+ }