acrylblends 1.0.6 → 1.0.8

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 (40) hide show
  1. package/dist/index.js +272 -1
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/guides/production.md +3 -3
  4. package/lib/{audit-GcLtvNLV.d.ts → audit-BD8_2pkh.d.ts} +25 -3
  5. package/lib/cordis/door.d.ts +2 -2
  6. package/lib/cordis/door.js +79 -6
  7. package/lib/door/host.d.ts +2 -2
  8. package/lib/door/host.js +1 -1
  9. package/lib/door/page.d.ts +24 -1
  10. package/lib/door/page.js +138 -9
  11. package/lib/door/protocol.d.ts +1 -1
  12. package/lib/door/protocol.js +1 -1
  13. package/lib/{protocol-Dt0AZBOz.js → protocol-CQ_if7Dg.js} +4 -0
  14. package/lib/{protocol-CR31M7K_.d.ts → protocol-a2aqrp2X.d.ts} +3 -0
  15. package/lib/{snapshot-text-CtQm3n8n.js → snapshot-text-m13Pk-AI.js} +95 -17
  16. package/package.json +1 -1
  17. package/plugins/acryl-door/plugin.json +3 -0
  18. package/plugins/acryl-door/src/cli/acryl.ts +33 -4
  19. package/plugins/acryl-door/src/host/channel.ts +107 -18
  20. package/plugins/acryl-door/src/host/door.ts +5 -2
  21. package/plugins/acryl-door/src/host/page-socket.ts +78 -0
  22. package/plugins/acryl-door/src/host/shim.ts +2 -1
  23. package/plugins/acryl-door/src/index.ts +10 -0
  24. package/plugins/acryl-door/src/page/channel.ts +67 -5
  25. package/plugins/acryl-door/src/page/driver/sensitivity.ts +39 -3
  26. package/plugins/acryl-door/src/page/index.ts +16 -1
  27. package/plugins/acryl-door/src/page/protocol.ts +3 -0
  28. package/plugins/acryl-door/src/page/socket-source.ts +75 -0
  29. package/plugins/agent-panel/client/main.ts +182 -20
  30. package/plugins/agent-panel/client/markdown.ts +219 -0
  31. package/plugins/agent-panel/headless.ts +20 -0
  32. package/plugins/agent-panel/index.ts +190 -50
  33. package/plugins/desktop/main.ts +50 -3
  34. package/plugins/desktop/menu.ts +60 -0
  35. package/plugins/desktop/window.ts +48 -2
  36. package/plugins/extensions/index.ts +81 -4
  37. package/templates/agent/AGENTS.md +4 -2
  38. package/templates/agent/src/app.ts +9 -1
  39. package/templates/agent/src/routes.ts +13 -3
  40. package/templates/agent/test/app.test.ts +27 -2
@@ -13,6 +13,7 @@ import { join } from 'node:path'
13
13
  import type { Context } from '@deepseek-ai/cordis'
14
14
  import z from '@deepseek-ai/schemastery'
15
15
  import { Acryl } from 'acryl-router'
16
+ import type { UpgradeWebSocket } from 'acryl-router/helpers'
16
17
  import { clientModules } from 'acryl-node-server/client-modules'
17
18
  import { auditPath, AuditLog } from './host/audit.ts'
18
19
  import { UiChannel } from './host/channel.ts'
@@ -23,6 +24,7 @@ import { DOOR_PREFIX, privateApi } from './host/http.ts'
23
24
  import { mcpRoutes } from './host/mcp-routes.ts'
24
25
  import { operatorRoutes } from './host/operator-routes.ts'
25
26
  import { pageChannelRoutes } from './host/page-routes.ts'
27
+ import { pageSocketRoutes } from './host/page-socket.ts'
26
28
  import { ShellChannel, shellRoutes } from './host/shell.ts'
27
29
  import { realNode, writeShims, type ShimTarget } from './host/shim.ts'
28
30
 
@@ -154,10 +156,18 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
154
156
  // An app whose router copy is older than this plugin has no `wrap`: the middleware then covers the routes mounted after the door, as the agent panel's does.
155
157
  if (typeof router.wrap === 'function') router.wrap(addScript)
156
158
  else ctx.router.route('/', new Acryl().use('*', addScript as never))
159
+ // The page channel also runs over a WebSocket, which is not one of the browser's six connections per host. Without the `ws` package or an adapter that upgrades, the pages use the stream.
160
+ let upgrade: UpgradeWebSocket | undefined
161
+ try {
162
+ upgrade = (ctx as unknown as { nodeServer?: { upgrades?: () => UpgradeWebSocket } }).nodeServer?.upgrades?.()
163
+ } catch (cause) {
164
+ reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
165
+ }
157
166
  ctx.router.route('/', privateApi(
158
167
  DOOR_PREFIX,
159
168
  pageModule,
160
169
  pageChannelRoutes(channel, { prefix: DOOR_PREFIX }),
170
+ ...(upgrade === undefined ? [] : [pageSocketRoutes(channel, { prefix: DOOR_PREFIX, upgrade })]),
161
171
  shellRoutes(DOOR_PREFIX, secret, shell),
162
172
  operatorRoutes(DOOR_PREFIX, secret, door, reportError),
163
173
  mcpRoutes(DOOR_PREFIX, secret, door, reportError),
@@ -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,38 +62,79 @@ 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 })
86
118
  }
87
119
 
120
+ /**
121
+ * Let go of the stream without ending the channel: the page is going away but the browser may keep it (the back/forward cache keeps a page that was left, still holding its
122
+ * connections). A browser allows six connections per host, so pages kept like that would queue the live page's own answers behind them. `resume` opens the stream again.
123
+ */
124
+ suspend(): void {
125
+ if (this.disposed) return
126
+ if (this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer)
127
+ this.reconnectTimer = undefined
128
+ this.stop()
129
+ }
130
+
131
+ /** The page is back (restored from the cache): open the stream again with the same window id. */
132
+ resume(): void {
133
+ if (this.disposed) return
134
+ this.backoff = BACKOFF_START_MS
135
+ this.connect()
136
+ }
137
+
88
138
  dispose(): void {
89
139
  this.disposed = true
90
140
  if (this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer)
@@ -119,6 +169,8 @@ export class PageChannel {
119
169
  // A malformed call carries no usable id; there is nothing to answer.
120
170
  return
121
171
  }
172
+ // Alive: said before anything is done, so the Host can tell a page that heard the call from one that is frozen or gone.
173
+ this.send({ t: 'ack', id: call.id })
122
174
  this.driver.handle(call.request).then(
123
175
  (value) => { this.send({ t: 'result', id: call.id, value }) },
124
176
  (cause: unknown) => {
@@ -129,9 +181,19 @@ export class PageChannel {
129
181
  }
130
182
 
131
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
132
187
  const post = this.options.post ?? ((url: string, body: string) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
133
188
  post(`${this.options.base}/channel/answer`, JSON.stringify({ window: this.options.windowId, message })).catch(() => {
134
189
  // The Host is gone or restarting; the call it made settles on its side.
135
190
  })
136
191
  }
137
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
+ }
@@ -3,11 +3,46 @@
3
3
  * secrets and payment fields (sensitive), and controls that govern the agent itself (protected).
4
4
  */
5
5
 
6
- /** Field names and labels that mean a secret or a payment detail. */
7
- const SENSITIVE_WORDS = /pass(?:word|code|phrase)|secret|token|api[-_ ]?key|credential|private[-_ ]?key|\bpin\b|card|cvv|cvc|iban|routing|account[-_ ]?number|ssn|social[-_ ]?security|one[-_ ]?time|otp/i
6
+ /**
7
+ * Field names and labels that unmistakably name a secret or a payment detail. A word that is only sometimes one is not here: a bare "card" is the title of a card on a board,
8
+ * "pin" a map marker, "routing" a page. What a field *is* decides first: a password input, an `autocomplete` of a credential or a payment detail, a value
9
+ * that looks like a key. The words only catch a field that says what it holds in plain text.
10
+ */
11
+ const SENSITIVE_WORDS = new RegExp(
12
+ [
13
+ 'pass(?:word|code|phrase)',
14
+ 'secret',
15
+ 'api[-_ ]?key',
16
+ 'private[-_ ]?key',
17
+ 'credential',
18
+ '\\btoken\\b',
19
+ '(?:pin|security)[-_ ]?(?:code|number)',
20
+ '\\bcvv\\b',
21
+ '\\bcvc\\b',
22
+ '(?:credit|debit|payment)?[-_ ]?card[-_ ]?(?:number|no|num|code|expir)',
23
+ '\\bcc[-_ ]?(?:num|number|exp|csc)',
24
+ 'iban',
25
+ '(?:bank[-_ ]?)?routing[-_ ]?number',
26
+ 'account[-_ ]?number',
27
+ '\\bssn\\b',
28
+ 'social[-_ ]?security',
29
+ 'one[-_ ]?time[-_ ]?(?:code|password)',
30
+ '\\botp\\b',
31
+ ].join('|'),
32
+ 'i',
33
+ )
8
34
  /** `autocomplete` values that browsers treat as credentials or payment data. */
9
35
  const SENSITIVE_AUTOCOMPLETE = /^(?:cc-|current-password|new-password|one-time-code)/
10
36
 
37
+ /** A value that is a key or a token by its shape: a known prefix, a JSON web token, or one long unbroken string that mixes lower case, upper case and digits (a git hash is lower-case hex and is not one). */
38
+ const KEY_PREFIX = /^(?:sk[-_]|pk[-_]|rk[-_]|ghp_|gho_|ghs_|github_pat_|glpat-|xox[abprs]-|AKIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{20,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.)/
39
+ export function looksLikeSecret(value: string): boolean {
40
+ const text = value.trim()
41
+ if (text.length < 20 || /\s/u.test(text)) return false
42
+ if (KEY_PREFIX.test(text)) return true
43
+ return text.length >= 32 && /^[A-Za-z0-9+/_=-]+$/u.test(text) && /[a-z]/u.test(text) && /[A-Z]/u.test(text) && /\d/u.test(text)
44
+ }
45
+
11
46
  const label = (el: Element, attr: string): string => el.getAttribute(attr) ?? ''
12
47
 
13
48
  /** True for a form field (or anything marked) that holds a secret or payment detail. */
@@ -17,7 +52,8 @@ export function isSensitive(el: Element): boolean {
17
52
  if (el instanceof HTMLInputElement && el.type === 'password') return true
18
53
  const autocomplete = label(el, 'autocomplete').trim().toLowerCase()
19
54
  if (autocomplete.split(/\s+/).some(token => SENSITIVE_AUTOCOMPLETE.test(token))) return true
20
- return SENSITIVE_WORDS.test([el.name, el.id, label(el, 'placeholder'), label(el, 'aria-label')].join(' '))
55
+ if (SENSITIVE_WORDS.test([el.name, el.id, label(el, 'placeholder'), label(el, 'aria-label')].join(' '))) return true
56
+ return !(el instanceof HTMLSelectElement) && looksLikeSecret(el.value)
21
57
  }
22
58
 
23
59
  /** Areas whose controls the agent may look at but never operate. */
@@ -22,15 +22,30 @@ 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)
37
+ // A page that is left may be kept by the browser (back/forward cache) with its stream open, and six open streams hold up every request of the live page: let go on the way out.
38
+ const leave = (): void => { channel.suspend() }
39
+ const back = (event: Event): void => { if ((event as PageTransitionEvent).persisted) channel.resume() }
40
+ view.addEventListener('pagehide', leave)
41
+ view.addEventListener('pageshow', back)
29
42
  document.addEventListener('visibilitychange', reportFocus)
30
43
  channel.connect()
31
44
  return () => {
32
45
  view.removeEventListener('focus', reportFocus)
33
46
  view.removeEventListener('blur', reportFocus)
47
+ view.removeEventListener('pagehide', leave)
48
+ view.removeEventListener('pageshow', back)
34
49
  document.removeEventListener('visibilitychange', reportFocus)
35
50
  channel.dispose()
36
51
  removeIndicator()
@@ -100,6 +100,8 @@ export type ChannelToPage =
100
100
  | { readonly t: 'call'; readonly id: number; readonly request: UiRequest }
101
101
  export type ChannelToHost =
102
102
  | { readonly t: 'focus'; readonly focused: boolean }
103
+ /** The page received call `id` and is about to run it: proof that it is alive, sent before anything is done. */
104
+ | { readonly t: 'ack'; readonly id: number }
103
105
  | { readonly t: 'result'; readonly id: number; readonly value: UiResult }
104
106
  | { readonly t: 'error'; readonly id: number; readonly code: UiErrorCode; readonly message: string }
105
107
 
@@ -198,6 +200,7 @@ export const WINDOW_ID = /^[A-Za-z0-9-]{1,80}$/u
198
200
  export function parseChannelToHost(value: unknown): ChannelToHost | null {
199
201
  if (!isRecord(value)) return null
200
202
  if (value.t === 'focus' && typeof value.focused === 'boolean') return { t: 'focus', focused: value.focused }
203
+ if (value.t === 'ack' && typeof value.id === 'number') return { t: 'ack', id: value.id }
201
204
  if (value.t === 'result' && typeof value.id === 'number' && isRecord(value.value)) return { t: 'result', id: value.id, value: value.value as unknown as UiResult }
202
205
  if (value.t === 'error' && typeof value.id === 'number' && typeof value.message === 'string' && UI_ERROR_CODES.includes(value.code as UiErrorCode)) {
203
206
  return { t: 'error', id: value.id, code: value.code as UiErrorCode, message: value.message.slice(0, 500) }
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  // The panel in the page: a button at the corner opens a side panel (a shadow root, so the app's CSS cannot touch it and it cannot touch the app's). It asks the app's
2
2
  // agent through POST /api/agent-panel/ask, shows the answer as it arrives, and reloads the page after a run that installed or removed a plugin.
3
3
  import { describeTool } from './describe.ts'
4
+ import { renderMarkdown } from './markdown.ts'
4
5
 
5
6
  const PREFIX = '/api/agent-panel'
6
7
 
@@ -36,6 +37,77 @@ async function* events(response: Response): AsyncGenerator<PanelEvent> {
36
37
  }
37
38
  }
38
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
+
39
111
  const css = `
40
112
  :host { all: initial }
41
113
  * { box-sizing: border-box; font: 14px system-ui, sans-serif }
@@ -51,11 +123,23 @@ header strong { flex: 1 }
51
123
  .me { background: #dbeafe; align-self: flex-end }
52
124
  .tool { font: 12px ui-monospace, monospace; color: #4b5563; background: none; padding: 0 }
53
125
  .err { background: #fee2e2 }
126
+ .msg.md { white-space: normal; overflow-wrap: anywhere }
127
+ .md > :first-child { margin-top: 0 } .md > :last-child { margin-bottom: 0 }
128
+ .md p, .md ul, .md ol, .md pre, .md blockquote { margin: 0 0 8px }
129
+ .md h3, .md h4, .md h5, .md h6 { margin: 10px 0 4px; font-weight: 600 } .md h3 { font-size: 16px } .md h4, .md h5, .md h6 { font-size: 14px }
130
+ .md ul, .md ol { padding-left: 20px } .md li { margin: 2px 0 }
131
+ .md code { font: 12px ui-monospace, monospace; background: #e5e7eb; padding: 1px 4px; border-radius: 4px }
132
+ .md pre { overflow: auto; background: #111827; color: #f9fafb; padding: 8px 10px; border-radius: 6px }
133
+ .md pre code { background: none; padding: 0; color: inherit; white-space: pre }
134
+ .md blockquote { border-left: 3px solid #d1d5db; padding-left: 10px; color: #4b5563 }
135
+ .md a { color: #1d4ed8 }
136
+ .working { font: 12px ui-monospace, monospace; color: #6b7280; padding: 0 }
54
137
  form { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-top: 1px solid #e5e7eb }
55
138
  textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border: 1px solid #d1d5db; border-radius: 6px }
56
139
  .row { display: flex; gap: 8px; align-items: center }
57
140
  .row button, header button { padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer }
58
141
  .row button.send { background: #1f2937; color: #fff; border-color: #1f2937 }
142
+ .row button:disabled { opacity: 0.45; cursor: not-allowed }
59
143
  `
60
144
 
61
145
  async function start(): Promise<void> {
@@ -135,8 +219,36 @@ async function start(): Promise<void> {
135
219
  }
136
220
  }
137
221
  driver.addEventListener('change', () => void choose())
222
+ // The panel sits beside the page, not over it: while it is open the page is narrower by the panel's width, so its header and right-hand columns stay in view. (An element of the page
223
+ // that is positioned fixed to the window is not part of that layout and can still lie under the panel.)
224
+ const root = document.documentElement
225
+ const docked = ['margin-right'] as const
226
+ const saved = new Map<string, [string, string]>()
227
+ const dock = (open: boolean): void => {
228
+ if (open) {
229
+ for (const property of docked)
230
+ saved.set(property, [
231
+ root.style.getPropertyValue(property),
232
+ root.style.getPropertyPriority(property),
233
+ ])
234
+ root.style.setProperty(
235
+ 'margin-right',
236
+ `${String(Math.min(380, Math.floor(window.innerWidth * 0.9)))}px`,
237
+ 'important',
238
+ )
239
+ } else {
240
+ for (const property of docked) {
241
+ const [value, priority] = saved.get(property) ?? ['', '']
242
+ if (value === '') root.style.removeProperty(property)
243
+ else root.style.setProperty(property, value, priority)
244
+ }
245
+ saved.clear()
246
+ }
247
+ }
138
248
  const show = (open: boolean): void => {
249
+ const was = panel.classList.contains('shown')
139
250
  panel.classList.toggle('shown', open)
251
+ if (open !== was) dock(open)
140
252
  if (open) input.focus()
141
253
  }
142
254
  $('button.open').addEventListener('click', () => show(true))
@@ -154,20 +266,74 @@ async function start(): Promise<void> {
154
266
  // loads next follows it again. Only Stop cancels it.
155
267
  const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
156
268
  let following = false
157
- 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
+ }
158
313
  /**
159
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
160
315
  * a run that had already ended before the page loaded is only shown, never reloads the page.
161
316
  */
162
- const watch = async (response: Response, own: boolean): Promise<void> => {
317
+ const watch = async (source: RunSource, own: boolean): Promise<void> => {
163
318
  let live = true
164
319
  following = true
165
320
  send.disabled = true
166
321
  stop.disabled = false
167
322
  let changed = false
168
323
  let answer: HTMLElement | undefined
324
+ let answered = ''
325
+ // A coding agent started headless says little until it is done, so the panel says it is working, and for how long.
326
+ const started = Date.now()
327
+ const working = line('', 'working')
328
+ const tick = (): void => {
329
+ const seconds = Math.floor((Date.now() - started) / 1000)
330
+ working.textContent = `Working... ${String(Math.floor(seconds / 60))}:${String(seconds % 60).padStart(2, '0')}`
331
+ log.append(working)
332
+ }
333
+ tick()
334
+ const timer = setInterval(tick, 1000)
169
335
  try {
170
- for await (const { event, data } of events(response)) {
336
+ for await (const { event, data } of source.events) {
171
337
  if (event === 'run') {
172
338
  live = data.ended !== true
173
339
  if (!own) {
@@ -180,8 +346,10 @@ async function start(): Promise<void> {
180
346
  if (recent) show(true)
181
347
  }
182
348
  } else if (event === 'text') {
183
- answer ??= line('')
184
- answer.textContent += String(data.text ?? '')
349
+ answer ??= line('', 'md')
350
+ answered += String(data.text ?? '')
351
+ answer.replaceChildren(renderMarkdown(answered, document))
352
+ log.scrollTop = log.scrollHeight
185
353
  } else if (event === 'tool') {
186
354
  const described = describeTool(data)
187
355
  if (described !== undefined) line(described, 'tool')
@@ -197,6 +365,8 @@ async function start(): Promise<void> {
197
365
  } catch (error) {
198
366
  line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
199
367
  } finally {
368
+ clearInterval(timer)
369
+ working.remove()
200
370
  following = false
201
371
  send.disabled = false
202
372
  stop.disabled = true
@@ -215,29 +385,21 @@ async function start(): Promise<void> {
215
385
  input.value = ''
216
386
  line(message, 'me')
217
387
  try {
218
- const response = await fetch(`${PREFIX}/ask`, {
219
- method: 'POST',
220
- headers,
221
- body: JSON.stringify({ message, driver: driver.value }),
222
- })
223
- if (!response.ok) {
224
- line(
225
- ((await response.json().catch(() => ({}))) as { error?: string }).error ??
226
- `The panel answered ${response.status}.`,
227
- 'err',
228
- )
388
+ const run = await openRun({ t: 'ask', message, driver: driver.value })
389
+ if (run === undefined) return
390
+ if ('error' in run) {
391
+ line(run.error, 'err')
229
392
  return
230
393
  }
231
- await watch(response, true)
394
+ await watch(run, true)
232
395
  } catch (error) {
233
396
  line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
234
397
  }
235
398
  })
236
399
  // The latest run, if there is one: shown again after a reload (its question, its events), and followed if it is still going.
237
400
  try {
238
- const latest = await fetch(`${PREFIX}/run`, { headers })
239
- if (latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream'))
240
- void watch(latest, false)
401
+ const latest = await openRun({ t: 'follow' })
402
+ if (latest !== undefined && !('error' in latest)) void watch(latest, false)
241
403
  } catch {
242
404
  // No run to show.
243
405
  }