acrylblends 1.0.10 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +15 -1
- package/dist/index.js +13 -7
- package/dist/index.js.map +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/guides/production.md +20 -0
- package/docs/framework/workshop/README.md +177 -274
- package/docs/framework/workshop/facilitator.md +107 -84
- package/lib/{audit-Dw-siYTp.d.ts → audit-BFpULBCj.d.ts} +15 -3
- package/lib/cordis/door.d.ts +3 -1
- package/lib/cordis/door.js +11 -1
- package/lib/cordis/state.d.ts +3 -2
- package/lib/cordis/state.js +370 -6
- package/lib/door/host.d.ts +1 -1
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -0
- package/lib/door/page.js +29 -7
- package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-BwM6T2nG.js} +36 -4
- package/package.json +1 -1
- package/plugins/acryl-door/src/host/channel.ts +42 -4
- package/plugins/acryl-door/src/host/door.ts +5 -0
- package/plugins/acryl-door/src/host/page-routes.ts +1 -1
- package/plugins/acryl-door/src/host/page-socket.ts +1 -1
- package/plugins/acryl-door/src/page/channel.ts +8 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +17 -3
- package/plugins/acryl-door/src/page/index.ts +6 -1
- package/plugins/acryl-door/src/page/indicator.ts +14 -6
- package/plugins/acryl-state/plugin.json +4 -3
- package/plugins/acryl-state/src/client-source.ts +244 -0
- package/plugins/acryl-state/src/service.ts +33 -6
- package/plugins/acryl-state/src/socket.ts +96 -0
- package/plugins/agent-panel/client/main.ts +105 -55
- package/plugins/agent-panel/index.ts +42 -5
- package/plugins/instance/index.ts +6 -3
- package/templates/agent/AGENTS.md +3 -1
- package/templates/agent/src/routes.ts +38 -5
- package/templates/agent/test/app.test.ts +38 -2
- package/templates/chat/src/main.ts +1 -6
- package/templates/nodejs/src/main.ts +1 -6
- package/templates/notes/src/main.ts +1 -6
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State over a WebSocket. A server-sent event stream holds one of the browser's six connections per host for as long as the page lives, and an app that follows a few keys in
|
|
3
|
+
* several windows uses them all; a WebSocket is not one of those six, and one socket carries every key of a page.
|
|
4
|
+
*
|
|
5
|
+
* `GET /api/state/ws` upgrades. Up, one JSON message per frame: `{ "t": "sub", "key": "<key>" }` and `{ "t": "unsub", "key": "<key>" }`. Down: `{ "key": "<key>", "value": <json> }` once on
|
|
6
|
+
* `sub` and after every change, `{ "key": "<key>", "error": "<why>" }` for a key that cannot be followed, and `{ "t": "ping" }` every {@link SOCKET_HEARTBEAT_MS} so a page can tell a quiet
|
|
7
|
+
* socket from a dead one. `GET /api/state/client.js` is the browser module that speaks this (`watch(key, callback)`), with the event stream as its fallback.
|
|
8
|
+
*
|
|
9
|
+
* A WebSocket has no CORS, so the upgrade is checked here ({@link refuseStranger}): a loopback peer on this port with a loopback `Host`, and an `Origin` that, when the request has one,
|
|
10
|
+
* is exactly this app's own origin. A program on this machine that sends no `Origin` (a script, a test) is let in, as it is for the JSON and stream routes; a web page of another
|
|
11
|
+
* origin, and a name that only resolves to this machine (DNS rebinding), are not.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Context, MiddlewareHandler } from 'acryl-router'
|
|
15
|
+
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
16
|
+
import { loopbackAuthority } from 'acryl-router/middleware'
|
|
17
|
+
import type { ReadonlyCell } from './cell.ts'
|
|
18
|
+
|
|
19
|
+
/** How often the server writes a `ping`; a page gives up on a socket that is silent for three of these. */
|
|
20
|
+
export const SOCKET_HEARTBEAT_MS = 15_000
|
|
21
|
+
|
|
22
|
+
/** How many keys one socket may follow; a page watches a handful. */
|
|
23
|
+
export const MAX_KEYS_PER_SOCKET = 256
|
|
24
|
+
|
|
25
|
+
/** What the socket needs of the state service. */
|
|
26
|
+
export interface Cells {
|
|
27
|
+
find(key: string): ReadonlyCell<unknown> | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StateSocketOptions {
|
|
31
|
+
readonly upgrade: UpgradeWebSocket
|
|
32
|
+
readonly heartbeatMs?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A refusal (403) unless the upgrade comes from this machine to this app, and from its own page when it comes from a page at all. */
|
|
36
|
+
export function refuseStranger(c: Context): Response | undefined {
|
|
37
|
+
const authority = loopbackAuthority(c)
|
|
38
|
+
if (authority === undefined) return c.json({ error: 'request authority rejected' }, 403)
|
|
39
|
+
const origin = c.req.header('origin')
|
|
40
|
+
const site = c.req.header('sec-fetch-site')
|
|
41
|
+
if ((origin !== undefined && origin !== authority.origin) || (site !== undefined && site !== 'same-origin')) {
|
|
42
|
+
return c.json({ error: 'only this app\'s own page may follow its state' }, 403)
|
|
43
|
+
}
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The route handler that upgrades and follows keys of `cells` for as long as the socket is open. */
|
|
48
|
+
export function stateSocket(cells: Cells, options: StateSocketOptions): MiddlewareHandler {
|
|
49
|
+
const heartbeatMs = options.heartbeatMs ?? SOCKET_HEARTBEAT_MS
|
|
50
|
+
return options.upgrade(() => {
|
|
51
|
+
const following = new Map<string, () => void>()
|
|
52
|
+
let beat: NodeJS.Timeout | undefined
|
|
53
|
+
return {
|
|
54
|
+
onOpen(_event, ws) {
|
|
55
|
+
beat = setInterval(() => {
|
|
56
|
+
try { ws.send(JSON.stringify({ t: 'ping' })) } catch { /* the close event follows */ }
|
|
57
|
+
}, heartbeatMs)
|
|
58
|
+
beat.unref()
|
|
59
|
+
},
|
|
60
|
+
onMessage(event, ws) {
|
|
61
|
+
if (typeof event.data !== 'string') return
|
|
62
|
+
let message: { t?: unknown, key?: unknown }
|
|
63
|
+
try { message = JSON.parse(event.data) as typeof message } catch { return }
|
|
64
|
+
if (typeof message.key !== 'string') return
|
|
65
|
+
const key = message.key
|
|
66
|
+
const tell = (frame: object): void => {
|
|
67
|
+
try { ws.send(JSON.stringify(frame)) } catch { /* the close event follows */ }
|
|
68
|
+
}
|
|
69
|
+
const value = (current: unknown): void => {
|
|
70
|
+
let text: string
|
|
71
|
+
try { text = JSON.stringify({ key, value: current === undefined ? null : current }) } catch { tell({ key, error: 'the value cannot be sent as JSON' }); return }
|
|
72
|
+
try { ws.send(text) } catch { /* the close event follows */ }
|
|
73
|
+
}
|
|
74
|
+
if (message.t === 'unsub') {
|
|
75
|
+
following.get(key)?.()
|
|
76
|
+
following.delete(key)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (message.t !== 'sub') return
|
|
80
|
+
const cell = cells.find(key)
|
|
81
|
+
if (cell === undefined) { tell({ key, error: 'no such state' }); return }
|
|
82
|
+
const known = following.has(key)
|
|
83
|
+
if (!known && following.size >= MAX_KEYS_PER_SOCKET) { tell({ key, error: 'too many keys on one socket' }); return }
|
|
84
|
+
// A key asked for twice is answered again with its value, and followed once.
|
|
85
|
+
if (known) { value(cell.get()); return }
|
|
86
|
+
value(cell.get())
|
|
87
|
+
following.set(key, cell.subscribe(value))
|
|
88
|
+
},
|
|
89
|
+
onClose() {
|
|
90
|
+
if (beat !== undefined) clearInterval(beat)
|
|
91
|
+
for (const stop of following.values()) stop()
|
|
92
|
+
following.clear()
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}, { textOnly: true })
|
|
96
|
+
}
|
|
@@ -42,70 +42,90 @@ interface RunSource {
|
|
|
42
42
|
readonly events: AsyncIterable<PanelEvent>
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** The door's id for this page, when the door is in it: sent with a question so the agent's calls (which name no window) drive this page and not another open one. */
|
|
46
|
+
function doorPage(): { page?: string } {
|
|
47
|
+
const published = (window as unknown as Record<symbol, { windowId?: unknown } | undefined>)[
|
|
48
|
+
Symbol.for('acryl.door.page')
|
|
49
|
+
]
|
|
50
|
+
return typeof published?.windowId === 'string' ? { page: published.windowId } : {}
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
/** A WebSocket is not one of the browser's six connections per host, so the panel's Stop and a run still work when two windows stream their own state. */
|
|
46
54
|
let socketBroken = false
|
|
47
55
|
let activeSocket: WebSocket | undefined
|
|
48
56
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
57
|
+
/** The server did not accept the token: it is not the one this page was given (the app restarted since, which `node --watch` does at every change of a file). */
|
|
58
|
+
class Refused extends Error {}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Open the run socket and send the token and the first action. Resolves once the server has accepted the token (its `accepted` frame), with the socket and its events: the
|
|
62
|
+
* frames of the run as panel events, until the socket closes. One set of handlers serves the socket for its whole life, so a frame is never handed to a handler that was
|
|
63
|
+
* replaced. Rejects with `Refused` when the token is not accepted, and with another error when the page cannot upgrade (a proxy, a server without `ws`): the caller falls back
|
|
64
|
+
* to the stream.
|
|
65
|
+
*/
|
|
66
|
+
function openSocket(
|
|
67
|
+
token: string,
|
|
68
|
+
action: Record<string, unknown>,
|
|
69
|
+
): Promise<{ socket: WebSocket; events: AsyncGenerator<PanelEvent> }> {
|
|
51
70
|
return new Promise((resolve, reject) => {
|
|
52
71
|
const socket = new WebSocket(
|
|
53
72
|
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${PREFIX}/run/ws`,
|
|
54
73
|
)
|
|
74
|
+
const queue: PanelEvent[] = []
|
|
75
|
+
let wake: (() => void) | undefined
|
|
76
|
+
let closed = false
|
|
77
|
+
let accepted = false
|
|
55
78
|
const timer = setTimeout(() => {
|
|
56
79
|
socket.close()
|
|
57
|
-
reject(new Error('the socket
|
|
80
|
+
reject(new Error('the socket was not accepted in time'))
|
|
58
81
|
}, 4000)
|
|
59
82
|
socket.onopen = () => {
|
|
60
|
-
clearTimeout(timer)
|
|
61
83
|
socket.send(JSON.stringify({ t: 'hello', token }))
|
|
62
84
|
socket.send(JSON.stringify(action))
|
|
63
|
-
resolve(socket)
|
|
64
85
|
}
|
|
65
|
-
socket.
|
|
66
|
-
|
|
67
|
-
|
|
86
|
+
socket.onmessage = (message) => {
|
|
87
|
+
try {
|
|
88
|
+
const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
|
|
89
|
+
if (frame.e === 'accepted' && !accepted) {
|
|
90
|
+
accepted = true
|
|
91
|
+
clearTimeout(timer)
|
|
92
|
+
resolve({ socket, events: read() })
|
|
93
|
+
} else if (typeof frame.e === 'string')
|
|
94
|
+
queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
|
|
95
|
+
} catch {
|
|
96
|
+
// Not a frame of ours.
|
|
97
|
+
}
|
|
98
|
+
wake?.()
|
|
68
99
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
|
|
80
|
-
if (typeof frame.e === 'string')
|
|
81
|
-
queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
|
|
82
|
-
} catch {
|
|
83
|
-
// Not a frame of ours.
|
|
100
|
+
socket.onclose = (event) => {
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
closed = true
|
|
103
|
+
if (!accepted)
|
|
104
|
+
reject(
|
|
105
|
+
event.code === 4401
|
|
106
|
+
? new Refused('the token was not accepted')
|
|
107
|
+
: new Error('the socket closed before it was accepted'),
|
|
108
|
+
)
|
|
109
|
+
wake?.()
|
|
84
110
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
closed = event
|
|
89
|
-
wake?.()
|
|
90
|
-
}
|
|
91
|
-
for (;;) {
|
|
92
|
-
const next = queue.shift()
|
|
93
|
-
if (next !== undefined) {
|
|
94
|
-
yield next
|
|
95
|
-
continue
|
|
111
|
+
socket.onerror = () => {
|
|
112
|
+
clearTimeout(timer)
|
|
113
|
+
if (!accepted) reject(new Error('the server refused the upgrade'))
|
|
96
114
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
115
|
+
async function* read(): AsyncGenerator<PanelEvent> {
|
|
116
|
+
for (;;) {
|
|
117
|
+
const next = queue.shift()
|
|
118
|
+
if (next !== undefined) {
|
|
119
|
+
yield next
|
|
120
|
+
continue
|
|
102
121
|
}
|
|
103
|
-
|
|
122
|
+
if (closed) return
|
|
123
|
+
await new Promise<void>((done) => {
|
|
124
|
+
wake = done
|
|
125
|
+
})
|
|
126
|
+
}
|
|
104
127
|
}
|
|
105
|
-
|
|
106
|
-
wake = resolve
|
|
107
|
-
})
|
|
108
|
-
}
|
|
128
|
+
})
|
|
109
129
|
}
|
|
110
130
|
|
|
111
131
|
const css = `
|
|
@@ -145,7 +165,7 @@ textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border
|
|
|
145
165
|
async function start(): Promise<void> {
|
|
146
166
|
const reply = await fetch(`${PREFIX}/session`)
|
|
147
167
|
if (!reply.ok) return
|
|
148
|
-
|
|
168
|
+
let session = (await reply.json()) as Session
|
|
149
169
|
|
|
150
170
|
const host = document.createElement('div')
|
|
151
171
|
host.id = 'agent-panel'
|
|
@@ -199,6 +219,7 @@ async function start(): Promise<void> {
|
|
|
199
219
|
onChange(change: { extension: string; action: string }): void
|
|
200
220
|
}): Promise<{ detach(): void; stop(): Promise<void> }>
|
|
201
221
|
}
|
|
222
|
+
await refreshSession()
|
|
202
223
|
terminal = await openTerminal({
|
|
203
224
|
container: term,
|
|
204
225
|
token: session.token,
|
|
@@ -265,26 +286,41 @@ async function start(): Promise<void> {
|
|
|
265
286
|
// A run belongs to the app, not to this page: it goes on when the page reloads (the door reloads it after a plugin change, an agent may move it to another page) and the page that
|
|
266
287
|
// loads next follows it again. Only Stop cancels it.
|
|
267
288
|
const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
|
|
289
|
+
/**
|
|
290
|
+
* The token belongs to one start of the app: `node --watch` restarts it at every change of a file, and a page left open across that restart holds a token the new server
|
|
291
|
+
* does not know. Ask for the session again; the caller tries once more with what it gets.
|
|
292
|
+
*/
|
|
293
|
+
const refreshSession = async (): Promise<void> => {
|
|
294
|
+
const reply = await fetch(`${PREFIX}/session`)
|
|
295
|
+
if (!reply.ok) return
|
|
296
|
+
session = (await reply.json()) as Session
|
|
297
|
+
headers['x-agent-panel-token'] = session.token
|
|
298
|
+
}
|
|
268
299
|
let following = false
|
|
269
|
-
|
|
300
|
+
const stopRun = (): void => {
|
|
270
301
|
// On the run's socket when there is one: a request would wait behind the browser's six connections per host.
|
|
271
302
|
if (activeSocket?.readyState === WebSocket.OPEN)
|
|
272
303
|
activeSocket.send(JSON.stringify({ t: 'stop' }))
|
|
273
304
|
else void fetch(`${PREFIX}/stop`, { method: 'POST', headers })
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
305
|
+
}
|
|
306
|
+
stop.addEventListener('click', stopRun)
|
|
307
|
+
// The door's "Agent is driving" bar has a Stop too (the same page): it ends this run.
|
|
308
|
+
window.addEventListener('acryl:stop-run', stopRun)
|
|
309
|
+
const REFUSED = Symbol('refused')
|
|
310
|
+
/** A run over the socket when the server upgrades, else over the stream, with one console warning saying why. `REFUSED` when the server does not know the token. */
|
|
311
|
+
const tryOpenRun = async (
|
|
312
|
+
action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
|
|
313
|
+
): Promise<RunSource | { error: string } | undefined | typeof REFUSED> => {
|
|
279
314
|
if (!socketBroken && typeof WebSocket !== 'undefined') {
|
|
280
315
|
try {
|
|
281
|
-
const socket = await openSocket(session.token, action)
|
|
316
|
+
const { socket, events: frames } = await openSocket(session.token, action)
|
|
282
317
|
activeSocket = socket
|
|
283
318
|
socket.addEventListener('close', () => {
|
|
284
319
|
if (activeSocket === socket) activeSocket = undefined
|
|
285
320
|
})
|
|
286
|
-
return { events:
|
|
321
|
+
return { events: frames }
|
|
287
322
|
} catch (cause) {
|
|
323
|
+
if (cause instanceof Refused) return REFUSED
|
|
288
324
|
socketBroken = true
|
|
289
325
|
console.warn(
|
|
290
326
|
`agent-panel: the run could not open a WebSocket (${cause instanceof Error ? cause.message : String(cause)}); using server-sent events, which count against the browser's six connections per host`,
|
|
@@ -295,8 +331,9 @@ async function start(): Promise<void> {
|
|
|
295
331
|
const response = await fetch(`${PREFIX}/ask`, {
|
|
296
332
|
method: 'POST',
|
|
297
333
|
headers,
|
|
298
|
-
body: JSON.stringify({ message: action.message, driver: action.driver }),
|
|
334
|
+
body: JSON.stringify({ message: action.message, driver: action.driver, page: action.page }),
|
|
299
335
|
})
|
|
336
|
+
if (response.status === 403) return REFUSED
|
|
300
337
|
if (!response.ok)
|
|
301
338
|
return {
|
|
302
339
|
error:
|
|
@@ -306,10 +343,23 @@ async function start(): Promise<void> {
|
|
|
306
343
|
return { events: events(response) }
|
|
307
344
|
}
|
|
308
345
|
const latest = await fetch(`${PREFIX}/run`, { headers })
|
|
346
|
+
if (latest.status === 403) return REFUSED
|
|
309
347
|
return latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream')
|
|
310
348
|
? { events: events(latest) }
|
|
311
349
|
: undefined
|
|
312
350
|
}
|
|
351
|
+
/** `tryOpenRun`, and when the token was refused, a new session and one more try without the person seeing anything; a second refusal is shown. */
|
|
352
|
+
const openRun = async (
|
|
353
|
+
action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
|
|
354
|
+
): Promise<RunSource | { error: string } | undefined> => {
|
|
355
|
+
const first = await tryOpenRun(action)
|
|
356
|
+
if (first !== REFUSED) return first
|
|
357
|
+
await refreshSession()
|
|
358
|
+
const second = await tryOpenRun(action)
|
|
359
|
+
return second === REFUSED
|
|
360
|
+
? { error: 'the panel was not accepted (reload the page and try again)' }
|
|
361
|
+
: second
|
|
362
|
+
}
|
|
313
363
|
/**
|
|
314
364
|
* Show one run, as its events come, until it ends. `own` is true for a run this page just started (its question is on screen); a run found on load is shown from its question, and
|
|
315
365
|
* a run that had already ended before the page loaded is only shown, never reloads the page.
|
|
@@ -400,7 +450,7 @@ async function start(): Promise<void> {
|
|
|
400
450
|
input.value = ''
|
|
401
451
|
line(message, 'me')
|
|
402
452
|
try {
|
|
403
|
-
const run = await openRun({ t: 'ask', message, driver: driver.value })
|
|
453
|
+
const run = await openRun({ t: 'ask', message, driver: driver.value, ...doorPage() })
|
|
404
454
|
if (run === undefined) return
|
|
405
455
|
if ('error' in run) {
|
|
406
456
|
line(run.error, 'err')
|
|
@@ -42,6 +42,11 @@ export const name = 'agent-panel'
|
|
|
42
42
|
export const inject = ['router', 'agent', 'extensions', 'appInstance']
|
|
43
43
|
|
|
44
44
|
const PREFIX = '/api/agent-panel'
|
|
45
|
+
|
|
46
|
+
/** The slice of the door the panel uses; the door is optional (it is a development plugin, and a different row). */
|
|
47
|
+
interface DoorPort {
|
|
48
|
+
driveFor(windowId: string): () => void
|
|
49
|
+
}
|
|
45
50
|
const CLIENT_DIR = join(import.meta.dirname, 'client')
|
|
46
51
|
|
|
47
52
|
export interface PanelConfig {
|
|
@@ -123,10 +128,14 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
123
128
|
.post(`${PREFIX}/ask`, async (c) => {
|
|
124
129
|
if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
|
|
125
130
|
return c.json({ error: "only the app's own page may ask" }, 403)
|
|
126
|
-
const body = (await c.req.json().catch(() => ({}))) as {
|
|
131
|
+
const body = (await c.req.json().catch(() => ({}))) as {
|
|
132
|
+
message?: unknown
|
|
133
|
+
driver?: unknown
|
|
134
|
+
page?: unknown
|
|
135
|
+
}
|
|
127
136
|
const checked = checkAsk(body.message, body.driver)
|
|
128
137
|
if ('error' in checked) return c.json({ error: checked.error }, checked.status)
|
|
129
|
-
const run = startRun(checked.driverName, checked.message, checked.driver)
|
|
138
|
+
const run = startRun(checked.driverName, checked.message, checked.driver, pageOf(body.page))
|
|
130
139
|
return follow(c, run, false)
|
|
131
140
|
})
|
|
132
141
|
// Stop: the only thing that cancels a run. A page that goes away (a reload, a move to another page, a closed tab) does not: the agent goes on, and the page picks it up again.
|
|
@@ -184,7 +193,15 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
184
193
|
}
|
|
185
194
|
let current: Run | undefined
|
|
186
195
|
let runs = 0
|
|
187
|
-
|
|
196
|
+
/** The door's id of the page that asked, when it sent one that looks like it. */
|
|
197
|
+
const pageOf = (raw: unknown): string | undefined =>
|
|
198
|
+
typeof raw === 'string' && /^[A-Za-z0-9-]{1,80}$/u.test(raw) ? raw : undefined
|
|
199
|
+
const startRun = (
|
|
200
|
+
driverName: string,
|
|
201
|
+
message: string,
|
|
202
|
+
driver: ChatDriver,
|
|
203
|
+
page?: string,
|
|
204
|
+
): Run => {
|
|
188
205
|
runs += 1
|
|
189
206
|
const run: Run = {
|
|
190
207
|
id: runs,
|
|
@@ -209,6 +226,9 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
209
226
|
})
|
|
210
227
|
ctx.effect(() => () => run.cancel.abort(), `agent-panel: run ${runs}`)
|
|
211
228
|
audit({ ask: 'start', driver: driverName, characters: message.length })
|
|
229
|
+
// The agent's door calls name no window: while this run goes, they are for the page whose panel asked, not for another tab or window that happens to be open.
|
|
230
|
+
const door = page === undefined ? undefined : (ctx.get('door') as DoorPort | undefined)
|
|
231
|
+
const releasePage = door?.driveFor(page as string)
|
|
212
232
|
void (async () => {
|
|
213
233
|
try {
|
|
214
234
|
for await (const { event, data } of driver.start({ message, signal: run.cancel.signal }))
|
|
@@ -218,6 +238,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
218
238
|
emit('error', { message: error instanceof Error ? error.message : String(error) })
|
|
219
239
|
} finally {
|
|
220
240
|
off()
|
|
241
|
+
releasePage?.()
|
|
221
242
|
run.ended = true
|
|
222
243
|
run.endedAt = Date.now()
|
|
223
244
|
audit({ ask: 'end', driver: driverName, changed })
|
|
@@ -339,7 +360,13 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
339
360
|
},
|
|
340
361
|
onMessage(event, ws) {
|
|
341
362
|
if (typeof event.data !== 'string') return
|
|
342
|
-
let message: {
|
|
363
|
+
let message: {
|
|
364
|
+
t?: unknown
|
|
365
|
+
token?: unknown
|
|
366
|
+
message?: unknown
|
|
367
|
+
driver?: unknown
|
|
368
|
+
page?: unknown
|
|
369
|
+
}
|
|
343
370
|
try {
|
|
344
371
|
message = JSON.parse(event.data) as typeof message
|
|
345
372
|
} catch {
|
|
@@ -352,6 +379,8 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
352
379
|
}
|
|
353
380
|
authed = true
|
|
354
381
|
if (hello !== undefined) clearTimeout(hello)
|
|
382
|
+
// The page waits for this before it counts the socket as open: a token it was given by an earlier start of the app is refused (4401) and it asks for a new one.
|
|
383
|
+
ws.send(JSON.stringify({ e: 'accepted', d: {} }))
|
|
355
384
|
return
|
|
356
385
|
}
|
|
357
386
|
const send = (e: string, d: unknown): void => {
|
|
@@ -374,7 +403,15 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
374
403
|
ws.close(1000, 'refused')
|
|
375
404
|
return
|
|
376
405
|
}
|
|
377
|
-
watch(
|
|
406
|
+
watch(
|
|
407
|
+
startRun(
|
|
408
|
+
checked.driverName,
|
|
409
|
+
checked.message,
|
|
410
|
+
checked.driver,
|
|
411
|
+
pageOf(message.page),
|
|
412
|
+
),
|
|
413
|
+
false,
|
|
414
|
+
)
|
|
378
415
|
} else if (message.t === 'follow') {
|
|
379
416
|
if (current === undefined) {
|
|
380
417
|
send('idle', {})
|
|
@@ -44,6 +44,9 @@ export const PORT_SPAN = 900
|
|
|
44
44
|
export const HOME_ENV = 'ACRYLBLENDS_HOME'
|
|
45
45
|
export const PORT_ENV = 'ACRYLBLENDS_PORT'
|
|
46
46
|
export const HOST_ENV = 'ACRYLBLENDS_HOST'
|
|
47
|
+
/** The names hosting platforms set: used when `ACRYLBLENDS_PORT` and `ACRYLBLENDS_HOST` are not. */
|
|
48
|
+
export const PLATFORM_PORT_ENV = 'PORT'
|
|
49
|
+
export const PLATFORM_HOST_ENV = 'HOST'
|
|
47
50
|
|
|
48
51
|
export class AppInstanceError extends Error {
|
|
49
52
|
override name = 'AppInstanceError'
|
|
@@ -134,7 +137,7 @@ export function withPort(instance: AppInstance, start: number): AppInstance {
|
|
|
134
137
|
|
|
135
138
|
/**
|
|
136
139
|
* Choose the instance. The only place that reads the environment: `ACRYLBLENDS_HOME` pins the home, `ACRYLBLENDS_PORT` sets the start port, `ACRYLBLENDS_HOST` the interface;
|
|
137
|
-
* otherwise the project folder decides.
|
|
140
|
+
* hosting platforms set `PORT` and `HOST` instead, and those are read when the `ACRYLBLENDS_` ones are not; otherwise the project folder decides.
|
|
138
141
|
*/
|
|
139
142
|
export function selectInstance(options: {
|
|
140
143
|
projectRoot: string
|
|
@@ -145,9 +148,9 @@ export function selectInstance(options: {
|
|
|
145
148
|
let instance = pinned
|
|
146
149
|
? pinnedInstance(pinned, { name: plainName(options.projectRoot), root: options.projectRoot })
|
|
147
150
|
: projectInstance(options.projectRoot)
|
|
148
|
-
const port = env[PORT_ENV]?.trim()
|
|
151
|
+
const port = env[PORT_ENV]?.trim() || env[PLATFORM_PORT_ENV]?.trim()
|
|
149
152
|
if (port) instance = withPort(instance, Number(port))
|
|
150
|
-
const host = env[HOST_ENV]?.trim()
|
|
153
|
+
const host = env[HOST_ENV]?.trim() || env[PLATFORM_HOST_ENV]?.trim()
|
|
151
154
|
if (host) instance = Object.freeze({ ...instance, host })
|
|
152
155
|
return instance
|
|
153
156
|
}
|
|
@@ -9,6 +9,8 @@ and an optional `check(ctx)` that receives the plugin's own context (so `ctx.rou
|
|
|
9
9
|
|
|
10
10
|
**Drive the app you build.** `.acryl/bin/acryl look` reads the open page as text with refs, and `acryl act click|type|press|scroll|wait --ref <ref>` presses its buttons, opens its sidebars and fills its forms (look again before each act: the refs change). `act navigate --path /settings` goes to another page of the app; a path the app does not serve answers `404 at /nope` and the window shows a 404 page, so navigate on from there. In the desktop app (`npm run desktop`) the app's windows and menu are verbs too: `acryl window list`, `acryl window open --path /settings`, `acryl look --window <id>` (and `act ... --window <id>`) to drive that window's page, `acryl window resize|focus|close --id <id>`, `acryl menu list` and `acryl menu click --path "File>New Note"`. Closing the last window and Quit end the desktop app, so they need `--force`: do not use it unless the person asks. The app has a `/settings` page already (the appearance: system, light or dark, kept in the browser) and the desktop app's menu has a Settings item that opens it; add a person's other settings to that page, not to a new one, so `acryl menu click --path "File>Settings..."` and `acryl window open --path /settings` keep opening them (on macOS the item is in the application menu). Without a desktop app these answer "no desktop window is attached; run npm run desktop".
|
|
11
11
|
|
|
12
|
-
**
|
|
12
|
+
**Pages you build follow the app's appearance.** The person chooses System, Light or Dark in `/settings`, and every open window follows it at once. A page you build gets that by putting `<script src="/appearance.js"></script>` in its `<head>` (before any other script) and colouring itself with the custom properties the script defines: `var(--bg)` for the page, `var(--fg)` for text, `var(--surface)` for cards and sidebars, `var(--border)`, `var(--muted)` for secondary text and `var(--accent)`. Do not write fixed colours (`#fff`, `white`, `#111`), `color-scheme`, or a `@media (prefers-color-scheme)` of your own: they ignore the person's choice (a page that does so stays light in a window set to Dark, or the other way round). `look` reports structure and text, not colours: check a page's colours by its CSS (every colour is a `var(--...)`), and tell the person what you did.
|
|
13
|
+
|
|
14
|
+
**Follow state with `watch`, not an `EventSource`.** A browser lets a page hold six connections to one host, across all the windows of the app, and an `EventSource` holds one for as long as the page is open: a page that streams a few things in two windows fills all six, and then every request waits. A cell of `ctx.state` (`ctx.state.cell('board.cards', [])`) is followed in the page with `import { watch } from '/api/state/client.js'` and `const stop = watch('board.cards', (cards) => { render(cards) })`: one WebSocket for the whole page carries every key (a WebSocket is not one of the six), the callback gets the value now and after every change, and `stop()` ends it. Without a WebSocket it falls back to one event stream per key and prints one warning in the console; then keep to two keys a page. Do not open an `EventSource` on `/api/state/<key>/events` yourself, and do not poll in a loop what `watch` already pushes.
|
|
13
15
|
|
|
14
16
|
**Production.** The agent, the builder and the extensions are development tools: `POST /ask` spends the model key and runs tools, a plugin installed live runs in this process, and none of it is behind a login. With `NODE_ENV=production` `src/app.ts` leaves the agent and the builder out, so `/ask`, `/extensions*` and `/tasks*` are not mounted; the page and `GET /health` stay. The extensions you installed (the folders of `plugins/`, committed with the app) still run in production, read-only: nothing can be installed or changed there. `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` keeps the named rows, and each one kept prints a `WARNING` line at start.
|
|
@@ -16,14 +16,41 @@ const DOOR_TAG =
|
|
|
16
16
|
/**
|
|
17
17
|
* The app's own routes on the router. Always: the page and `GET /health`. While the agent, the extensions and the task recorder run: `POST /ask` for the agent; `GET /extensions`
|
|
18
18
|
* (and `POST /extensions/approve` and `/deny`) and `GET /tasks` to observe and steer what it did (every run is a durable task owning one task per tool call); `POST /tasks/cancel`.
|
|
19
|
-
* What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events
|
|
19
|
+
* What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events` (a page follows them with `watch` of `/api/state/client.js`, over one WebSocket). In production `src/app.ts` leaves the agent and builder rows out
|
|
20
20
|
* and starts the extensions read-only (unless `ACRYL_PRODUCTION_KEEP` names them), and then those routes are not mounted at all. The mounts are removed when the plugin is disposed.
|
|
21
21
|
*/
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* The app's look: the person's choice from /settings (kept in this browser), as `GET /appearance.js`. One script tag in a page's head (`<script src="/appearance.js"></script>`) applies it
|
|
24
|
+
* before the page paints, again whenever another window of the app changes it (the `storage` event), and defines the colours as custom properties for the page to use:
|
|
25
|
+
* `--bg`, `--fg`, `--muted`, `--surface`, `--border`, `--accent` (`html[data-theme=dark|light]` is set when the person chose one, and the system's choice decides otherwise). A page the agent builds
|
|
26
|
+
* carries the tag and colours itself with `var(--bg)` and the rest, and so follows Dark and Light.
|
|
27
|
+
*/
|
|
28
|
+
const APPEARANCE_JS = `(function () {
|
|
29
|
+
var root = document.documentElement
|
|
30
|
+
function apply() {
|
|
31
|
+
var saved
|
|
32
|
+
try { saved = localStorage.getItem('theme') } catch (e) { /* storage blocked: follow the system */ }
|
|
33
|
+
if (saved === 'dark' || saved === 'light') root.dataset.theme = saved
|
|
34
|
+
else delete root.dataset.theme
|
|
35
|
+
}
|
|
36
|
+
apply()
|
|
37
|
+
addEventListener('storage', function (event) { if (event.key === null || event.key === 'theme') apply() })
|
|
38
|
+
var light = '--bg:#ffffff;--fg:#111827;--muted:#6b7280;--surface:#f3f4f6;--border:#d1d5db;--accent:#2563eb'
|
|
39
|
+
var dark = '--bg:#111827;--fg:#f9fafb;--muted:#9ca3af;--surface:#1f2937;--border:#374151;--accent:#60a5fa'
|
|
40
|
+
var style = document.createElement('style')
|
|
41
|
+
style.id = 'appearance'
|
|
42
|
+
style.textContent = ':root{color-scheme:light dark;' + light + '}'
|
|
43
|
+
+ '@media (prefers-color-scheme:dark){html:not([data-theme]){' + dark + '}}'
|
|
44
|
+
+ 'html[data-theme=dark]{color-scheme:dark;' + dark + '}'
|
|
45
|
+
+ 'html[data-theme=light]{color-scheme:light;' + light + '}'
|
|
46
|
+
+ 'html{background:var(--bg);color:var(--fg)}'
|
|
47
|
+
;(document.head || root).appendChild(style)
|
|
48
|
+
})()
|
|
49
|
+
`
|
|
50
|
+
/** The home and settings pages' own layout; their colours come from /appearance.js. */
|
|
23
51
|
const APPEARANCE_STYLE =
|
|
24
|
-
'<style
|
|
25
|
-
const APPEARANCE_SCRIPT =
|
|
26
|
-
"<script>(function(){try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t}catch(e){}})()</script>"
|
|
52
|
+
'<style>body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:42rem;padding:0 1rem}a{color:inherit}</style>'
|
|
53
|
+
const APPEARANCE_SCRIPT = '<script src="/appearance.js"></script>'
|
|
27
54
|
|
|
28
55
|
/** `/settings`: the one setting the template has, the appearance. The desktop app's Settings menu item opens this page. */
|
|
29
56
|
const settingsPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Settings - ${APP_NAME}</title>${APPEARANCE_STYLE}${APPEARANCE_SCRIPT}</head><body><h1>Settings</h1><p><label for="theme">Appearance</label> <select id="theme" name="theme"><option value="system">Match the system</option><option value="light">Light</option><option value="dark">Dark</option></select></p><p id="saved" role="status"></p><p><a href="/">Back to ${APP_NAME}</a></p><script>(function(){var select=document.getElementById('theme'),saved=document.getElementById('saved');try{select.value=localStorage.getItem('theme')||'system'}catch(e){}select.addEventListener('change',function(){var value=select.value;try{if(value==='system'){localStorage.removeItem('theme');delete document.documentElement.dataset.theme}else{localStorage.setItem('theme',value);document.documentElement.dataset.theme=value}saved.textContent='Saved: '+select.options[select.selectedIndex].text}catch(e){saved.textContent='This browser would not keep the setting.'}})})()</script></body></html>`
|
|
@@ -52,6 +79,12 @@ export const routes = {
|
|
|
52
79
|
)
|
|
53
80
|
.get('/health', (c) => c.json({ ok: true }))
|
|
54
81
|
.get('/settings', (c) => c.html(settingsPage))
|
|
82
|
+
.get('/appearance.js', (c) =>
|
|
83
|
+
c.body(APPEARANCE_JS, 200, {
|
|
84
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
85
|
+
'cache-control': 'no-cache',
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
55
88
|
ctx.router.route('/', base)
|
|
56
89
|
// The routes that give a visitor the agent: mounted only while the agent, the extensions and the tasks run (see the header).
|
|
57
90
|
ctx.inject(['agent', 'extensions', 'tasks'], (inner) => {
|
|
@@ -156,11 +156,47 @@ test('the app has a /settings page with the appearance setting, and the home pag
|
|
|
156
156
|
assert.match(page, /<select id="theme"/)
|
|
157
157
|
for (const option of ['System', 'Light', 'Dark'])
|
|
158
158
|
assert.match(page, new RegExp(`>(?:Match the )?${option.toLowerCase()}|>${option}<`, 'i'))
|
|
159
|
-
// The setting is kept in the browser and applied to the home page before it paints.
|
|
159
|
+
// The setting is kept in the browser and applied to the home page before it paints, by the one script every page of the app can carry.
|
|
160
160
|
const home = await (
|
|
161
161
|
await app.root.router.request('/', { headers: { accept: 'text/html' } })
|
|
162
162
|
).text()
|
|
163
|
-
assert.match(home,
|
|
163
|
+
assert.match(home, /<script src="\/appearance\.js"><\/script>/)
|
|
164
|
+
assert.match(page, /<script src="\/appearance\.js"><\/script>/)
|
|
164
165
|
// ... without leaving a global behind for the page's own code to collide with.
|
|
165
166
|
assert.doesNotMatch(home, /<script>\s*(?:var|let|const) /)
|
|
167
|
+
const script = await app.root.router.request('/appearance.js')
|
|
168
|
+
assert.equal(script.status, 200)
|
|
169
|
+
assert.match(script.headers.get('content-type') ?? '', /javascript/)
|
|
170
|
+
const code = await script.text()
|
|
171
|
+
assert.match(code, /localStorage\.getItem\('theme'\)/)
|
|
172
|
+
// A change made in another window of the app is applied here at once, and the colours are custom properties for a page to use.
|
|
173
|
+
assert.match(code, /addEventListener\('storage'/)
|
|
174
|
+
for (const token of ['--bg', '--fg', '--muted', '--surface', '--border', '--accent'])
|
|
175
|
+
assert.ok(code.includes(token), token)
|
|
176
|
+
assert.match(code, /prefers-color-scheme:dark/)
|
|
177
|
+
// Run against a stand-in page: it applies the saved choice, follows a change made in another window, and goes back to the system's choice when the setting is removed.
|
|
178
|
+
const storage = new Map<string, string>([['theme', 'dark']])
|
|
179
|
+
const dataset: Record<string, string> = {}
|
|
180
|
+
const appended: Array<{ id: string; textContent: string }> = []
|
|
181
|
+
const listeners: Array<(event: { key: string | null }) => void> = []
|
|
182
|
+
new Function('document', 'localStorage', 'addEventListener', code)(
|
|
183
|
+
{
|
|
184
|
+
documentElement: { dataset },
|
|
185
|
+
head: { appendChild: (style: { id: string; textContent: string }) => appended.push(style) },
|
|
186
|
+
createElement: () => ({ id: '', textContent: '' }),
|
|
187
|
+
},
|
|
188
|
+
{ getItem: (key: string) => storage.get(key) ?? null },
|
|
189
|
+
(_type: string, listener: (event: { key: string | null }) => void) =>
|
|
190
|
+
listeners.push(listener),
|
|
191
|
+
)
|
|
192
|
+
assert.equal(dataset.theme, 'dark')
|
|
193
|
+
assert.equal(appended[0]?.id, 'appearance')
|
|
194
|
+
storage.set('theme', 'light')
|
|
195
|
+
for (const listener of listeners) listener({ key: 'theme' })
|
|
196
|
+
assert.equal(dataset.theme, 'light')
|
|
197
|
+
storage.delete('theme')
|
|
198
|
+
for (const listener of listeners) listener({ key: 'something-else' })
|
|
199
|
+
assert.equal(dataset.theme, 'light')
|
|
200
|
+
for (const listener of listeners) listener({ key: null })
|
|
201
|
+
assert.equal(dataset.theme, undefined)
|
|
166
202
|
}))
|
|
@@ -5,12 +5,7 @@ import { createApp } from './app.ts'
|
|
|
5
5
|
// The composition root: the one place that reads the environment and chooses where the app lives.
|
|
6
6
|
const chosen = selectInstance({
|
|
7
7
|
projectRoot: fileURLToPath(new URL('..', import.meta.url)),
|
|
8
|
-
|
|
9
|
-
env: {
|
|
10
|
-
...(process.env.PORT ? { ACRYLBLENDS_PORT: process.env.PORT } : {}),
|
|
11
|
-
...(process.env.HOST ? { ACRYLBLENDS_HOST: process.env.HOST } : {}),
|
|
12
|
-
...process.env,
|
|
13
|
-
},
|
|
8
|
+
env: process.env,
|
|
14
9
|
})
|
|
15
10
|
const app = await createApp(chosen, {
|
|
16
11
|
...(process.env.CHAT_DB ? { database: process.env.CHAT_DB } : {}),
|
|
@@ -6,12 +6,7 @@ import { APP_NAME } from './routes.ts'
|
|
|
6
6
|
// The composition root: the one place that reads the environment (through selectInstance) and chooses where the app lives.
|
|
7
7
|
const chosen = selectInstance({
|
|
8
8
|
projectRoot: fileURLToPath(new URL('..', import.meta.url)),
|
|
9
|
-
|
|
10
|
-
env: {
|
|
11
|
-
...(process.env.PORT ? { ACRYLBLENDS_PORT: process.env.PORT } : {}),
|
|
12
|
-
...(process.env.HOST ? { ACRYLBLENDS_HOST: process.env.HOST } : {}),
|
|
13
|
-
...process.env,
|
|
14
|
-
},
|
|
9
|
+
env: process.env,
|
|
15
10
|
})
|
|
16
11
|
const app = await createApp(chosen)
|
|
17
12
|
console.log(`${APP_NAME} listening on ${app.url} (data in ${chosen.home})`)
|