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.
- package/dist/index.js +272 -1
- package/dist/index.js.map +1 -1
- package/docs/framework/guides/production.md +3 -3
- package/lib/{audit-GcLtvNLV.d.ts → audit-BD8_2pkh.d.ts} +25 -3
- package/lib/cordis/door.d.ts +2 -2
- package/lib/cordis/door.js +79 -6
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +24 -1
- package/lib/door/page.js +138 -9
- package/lib/door/protocol.d.ts +1 -1
- package/lib/door/protocol.js +1 -1
- package/lib/{protocol-Dt0AZBOz.js → protocol-CQ_if7Dg.js} +4 -0
- package/lib/{protocol-CR31M7K_.d.ts → protocol-a2aqrp2X.d.ts} +3 -0
- package/lib/{snapshot-text-CtQm3n8n.js → snapshot-text-m13Pk-AI.js} +95 -17
- package/package.json +1 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +33 -4
- package/plugins/acryl-door/src/host/channel.ts +107 -18
- package/plugins/acryl-door/src/host/door.ts +5 -2
- package/plugins/acryl-door/src/host/page-socket.ts +78 -0
- package/plugins/acryl-door/src/host/shim.ts +2 -1
- package/plugins/acryl-door/src/index.ts +10 -0
- package/plugins/acryl-door/src/page/channel.ts +67 -5
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +39 -3
- package/plugins/acryl-door/src/page/index.ts +16 -1
- package/plugins/acryl-door/src/page/protocol.ts +3 -0
- package/plugins/acryl-door/src/page/socket-source.ts +75 -0
- package/plugins/agent-panel/client/main.ts +182 -20
- package/plugins/agent-panel/client/markdown.ts +219 -0
- package/plugins/agent-panel/headless.ts +20 -0
- package/plugins/agent-panel/index.ts +190 -50
- package/plugins/desktop/main.ts +50 -3
- package/plugins/desktop/menu.ts +60 -0
- package/plugins/desktop/window.ts +48 -2
- package/plugins/extensions/index.ts +81 -4
- package/templates/agent/AGENTS.md +4 -2
- package/templates/agent/src/app.ts +9 -1
- package/templates/agent/src/routes.ts +13 -3
- 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
|
|
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',
|
|
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
|
-
|
|
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
|
-
/**
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', () =>
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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(
|
|
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
|
|
239
|
-
if (latest
|
|
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
|
}
|