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.
Files changed (39) hide show
  1. package/dist/index.d.ts +15 -1
  2. package/dist/index.js +13 -7
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/api/summary.md +1 -1
  5. package/docs/framework/guides/production.md +20 -0
  6. package/docs/framework/workshop/README.md +177 -274
  7. package/docs/framework/workshop/facilitator.md +107 -84
  8. package/lib/{audit-Dw-siYTp.d.ts → audit-BFpULBCj.d.ts} +15 -3
  9. package/lib/cordis/door.d.ts +3 -1
  10. package/lib/cordis/door.js +11 -1
  11. package/lib/cordis/state.d.ts +3 -2
  12. package/lib/cordis/state.js +370 -6
  13. package/lib/door/host.d.ts +1 -1
  14. package/lib/door/host.js +1 -1
  15. package/lib/door/page.d.ts +10 -0
  16. package/lib/door/page.js +29 -7
  17. package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-BwM6T2nG.js} +36 -4
  18. package/package.json +1 -1
  19. package/plugins/acryl-door/src/host/channel.ts +42 -4
  20. package/plugins/acryl-door/src/host/door.ts +5 -0
  21. package/plugins/acryl-door/src/host/page-routes.ts +1 -1
  22. package/plugins/acryl-door/src/host/page-socket.ts +1 -1
  23. package/plugins/acryl-door/src/page/channel.ts +8 -0
  24. package/plugins/acryl-door/src/page/driver/driver.ts +17 -3
  25. package/plugins/acryl-door/src/page/index.ts +6 -1
  26. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  27. package/plugins/acryl-state/plugin.json +4 -3
  28. package/plugins/acryl-state/src/client-source.ts +244 -0
  29. package/plugins/acryl-state/src/service.ts +33 -6
  30. package/plugins/acryl-state/src/socket.ts +96 -0
  31. package/plugins/agent-panel/client/main.ts +105 -55
  32. package/plugins/agent-panel/index.ts +42 -5
  33. package/plugins/instance/index.ts +6 -3
  34. package/templates/agent/AGENTS.md +3 -1
  35. package/templates/agent/src/routes.ts +38 -5
  36. package/templates/agent/test/app.test.ts +38 -2
  37. package/templates/chat/src/main.ts +1 -6
  38. package/templates/nodejs/src/main.ts +1 -6
  39. 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
- /** Open the run socket and send the token and the first action. Rejects when the page cannot upgrade (a proxy, a server without `ws`): the caller falls back to the stream. */
50
- function openSocket(token: string, action: Record<string, unknown>): Promise<WebSocket> {
57
+ /** The server did not accept the token: it is not the one this page was given (the app restarted since, which `node --watch` does at every change of a file). */
58
+ class Refused extends Error {}
59
+
60
+ /**
61
+ * Open the run socket and send the token and the first action. Resolves once the server has accepted the token (its `accepted` frame), with the socket and its events: the
62
+ * frames of the run as panel events, until the socket closes. One set of handlers serves the socket for its whole life, so a frame is never handed to a handler that was
63
+ * replaced. Rejects with `Refused` when the token is not accepted, and with another error when the page cannot upgrade (a proxy, a server without `ws`): the caller falls back
64
+ * to the stream.
65
+ */
66
+ function openSocket(
67
+ token: string,
68
+ action: Record<string, unknown>,
69
+ ): Promise<{ socket: WebSocket; events: AsyncGenerator<PanelEvent> }> {
51
70
  return new Promise((resolve, reject) => {
52
71
  const socket = new WebSocket(
53
72
  `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${PREFIX}/run/ws`,
54
73
  )
74
+ const queue: PanelEvent[] = []
75
+ let wake: (() => void) | undefined
76
+ let closed = false
77
+ let accepted = false
55
78
  const timer = setTimeout(() => {
56
79
  socket.close()
57
- reject(new Error('the socket did not open in time'))
80
+ reject(new Error('the socket was not accepted in time'))
58
81
  }, 4000)
59
82
  socket.onopen = () => {
60
- clearTimeout(timer)
61
83
  socket.send(JSON.stringify({ t: 'hello', token }))
62
84
  socket.send(JSON.stringify(action))
63
- resolve(socket)
64
85
  }
65
- socket.onerror = () => {
66
- clearTimeout(timer)
67
- reject(new Error('the server refused the upgrade'))
86
+ socket.onmessage = (message) => {
87
+ try {
88
+ const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
89
+ if (frame.e === 'accepted' && !accepted) {
90
+ accepted = true
91
+ clearTimeout(timer)
92
+ resolve({ socket, events: read() })
93
+ } else if (typeof frame.e === 'string')
94
+ queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
95
+ } catch {
96
+ // Not a frame of ours.
97
+ }
98
+ wake?.()
68
99
  }
69
- })
70
- }
71
-
72
- /** The frames of an open run socket as panel events, until it closes. */
73
- async function* socketEvents(socket: WebSocket): AsyncGenerator<PanelEvent> {
74
- const queue: PanelEvent[] = []
75
- let wake: (() => void) | undefined
76
- let closed: CloseEvent | undefined
77
- socket.onmessage = (message) => {
78
- try {
79
- const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
80
- if (typeof frame.e === 'string')
81
- queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
82
- } catch {
83
- // Not a frame of ours.
100
+ socket.onclose = (event) => {
101
+ clearTimeout(timer)
102
+ closed = true
103
+ if (!accepted)
104
+ reject(
105
+ event.code === 4401
106
+ ? new Refused('the token was not accepted')
107
+ : new Error('the socket closed before it was accepted'),
108
+ )
109
+ wake?.()
84
110
  }
85
- wake?.()
86
- }
87
- socket.onclose = (event) => {
88
- closed = event
89
- wake?.()
90
- }
91
- for (;;) {
92
- const next = queue.shift()
93
- if (next !== undefined) {
94
- yield next
95
- continue
111
+ socket.onerror = () => {
112
+ clearTimeout(timer)
113
+ if (!accepted) reject(new Error('the server refused the upgrade'))
96
114
  }
97
- if (closed !== undefined) {
98
- if (closed.code === 4401)
99
- yield {
100
- event: 'error',
101
- data: { message: 'the panel was not accepted (reload the page and try again)' },
115
+ async function* read(): AsyncGenerator<PanelEvent> {
116
+ for (;;) {
117
+ const next = queue.shift()
118
+ if (next !== undefined) {
119
+ yield next
120
+ continue
102
121
  }
103
- return
122
+ if (closed) return
123
+ await new Promise<void>((done) => {
124
+ wake = done
125
+ })
126
+ }
104
127
  }
105
- await new Promise<void>((resolve) => {
106
- wake = resolve
107
- })
108
- }
128
+ })
109
129
  }
110
130
 
111
131
  const css = `
@@ -145,7 +165,7 @@ textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border
145
165
  async function start(): Promise<void> {
146
166
  const reply = await fetch(`${PREFIX}/session`)
147
167
  if (!reply.ok) return
148
- const session = (await reply.json()) as Session
168
+ let session = (await reply.json()) as Session
149
169
 
150
170
  const host = document.createElement('div')
151
171
  host.id = 'agent-panel'
@@ -199,6 +219,7 @@ async function start(): Promise<void> {
199
219
  onChange(change: { extension: string; action: string }): void
200
220
  }): Promise<{ detach(): void; stop(): Promise<void> }>
201
221
  }
222
+ await refreshSession()
202
223
  terminal = await openTerminal({
203
224
  container: term,
204
225
  token: session.token,
@@ -265,26 +286,41 @@ async function start(): Promise<void> {
265
286
  // A run belongs to the app, not to this page: it goes on when the page reloads (the door reloads it after a plugin change, an agent may move it to another page) and the page that
266
287
  // loads next follows it again. Only Stop cancels it.
267
288
  const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
289
+ /**
290
+ * The token belongs to one start of the app: `node --watch` restarts it at every change of a file, and a page left open across that restart holds a token the new server
291
+ * does not know. Ask for the session again; the caller tries once more with what it gets.
292
+ */
293
+ const refreshSession = async (): Promise<void> => {
294
+ const reply = await fetch(`${PREFIX}/session`)
295
+ if (!reply.ok) return
296
+ session = (await reply.json()) as Session
297
+ headers['x-agent-panel-token'] = session.token
298
+ }
268
299
  let following = false
269
- stop.addEventListener('click', () => {
300
+ const stopRun = (): void => {
270
301
  // On the run's socket when there is one: a request would wait behind the browser's six connections per host.
271
302
  if (activeSocket?.readyState === WebSocket.OPEN)
272
303
  activeSocket.send(JSON.stringify({ t: 'stop' }))
273
304
  else void fetch(`${PREFIX}/stop`, { method: 'POST', headers })
274
- })
275
- /** A run over the socket when the server upgrades, else over the stream, with one console warning saying why. */
276
- const openRun = async (
277
- action: { t: 'ask'; message: string; driver: string } | { t: 'follow' },
278
- ): Promise<RunSource | { error: string } | undefined> => {
305
+ }
306
+ stop.addEventListener('click', stopRun)
307
+ // The door's "Agent is driving" bar has a Stop too (the same page): it ends this run.
308
+ window.addEventListener('acryl:stop-run', stopRun)
309
+ const REFUSED = Symbol('refused')
310
+ /** A run over the socket when the server upgrades, else over the stream, with one console warning saying why. `REFUSED` when the server does not know the token. */
311
+ const tryOpenRun = async (
312
+ action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
313
+ ): Promise<RunSource | { error: string } | undefined | typeof REFUSED> => {
279
314
  if (!socketBroken && typeof WebSocket !== 'undefined') {
280
315
  try {
281
- const socket = await openSocket(session.token, action)
316
+ const { socket, events: frames } = await openSocket(session.token, action)
282
317
  activeSocket = socket
283
318
  socket.addEventListener('close', () => {
284
319
  if (activeSocket === socket) activeSocket = undefined
285
320
  })
286
- return { events: socketEvents(socket) }
321
+ return { events: frames }
287
322
  } catch (cause) {
323
+ if (cause instanceof Refused) return REFUSED
288
324
  socketBroken = true
289
325
  console.warn(
290
326
  `agent-panel: the run could not open a WebSocket (${cause instanceof Error ? cause.message : String(cause)}); using server-sent events, which count against the browser's six connections per host`,
@@ -295,8 +331,9 @@ async function start(): Promise<void> {
295
331
  const response = await fetch(`${PREFIX}/ask`, {
296
332
  method: 'POST',
297
333
  headers,
298
- body: JSON.stringify({ message: action.message, driver: action.driver }),
334
+ body: JSON.stringify({ message: action.message, driver: action.driver, page: action.page }),
299
335
  })
336
+ if (response.status === 403) return REFUSED
300
337
  if (!response.ok)
301
338
  return {
302
339
  error:
@@ -306,10 +343,23 @@ async function start(): Promise<void> {
306
343
  return { events: events(response) }
307
344
  }
308
345
  const latest = await fetch(`${PREFIX}/run`, { headers })
346
+ if (latest.status === 403) return REFUSED
309
347
  return latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream')
310
348
  ? { events: events(latest) }
311
349
  : undefined
312
350
  }
351
+ /** `tryOpenRun`, and when the token was refused, a new session and one more try without the person seeing anything; a second refusal is shown. */
352
+ const openRun = async (
353
+ action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
354
+ ): Promise<RunSource | { error: string } | undefined> => {
355
+ const first = await tryOpenRun(action)
356
+ if (first !== REFUSED) return first
357
+ await refreshSession()
358
+ const second = await tryOpenRun(action)
359
+ return second === REFUSED
360
+ ? { error: 'the panel was not accepted (reload the page and try again)' }
361
+ : second
362
+ }
313
363
  /**
314
364
  * Show one run, as its events come, until it ends. `own` is true for a run this page just started (its question is on screen); a run found on load is shown from its question, and
315
365
  * a run that had already ended before the page loaded is only shown, never reloads the page.
@@ -400,7 +450,7 @@ async function start(): Promise<void> {
400
450
  input.value = ''
401
451
  line(message, 'me')
402
452
  try {
403
- const run = await openRun({ t: 'ask', message, driver: driver.value })
453
+ const run = await openRun({ t: 'ask', message, driver: driver.value, ...doorPage() })
404
454
  if (run === undefined) return
405
455
  if ('error' in run) {
406
456
  line(run.error, 'err')
@@ -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 { message?: unknown; driver?: unknown }
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
- const startRun = (driverName: string, message: string, driver: ChatDriver): Run => {
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: { t?: unknown; token?: unknown; message?: unknown; driver?: unknown }
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(startRun(checked.driverName, checked.message, checked.driver), false)
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
- **Keep to two event streams a page.** 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. The door and the panel use none (they are WebSockets), but a page you build that streams several things in two windows can fill all six, and then every request waits. Give a page at most two streams: share one stream for several cells, or fetch what changes now and then instead.
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`. In production `src/app.ts` leaves the agent and builder rows out
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
- /** The page's look: the person's choice from /settings (kept in this browser), applied before the page paints. */
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>:root{color-scheme:light dark}html[data-theme=dark]{color-scheme:dark;background:#111827;color:#f9fafb}html[data-theme=light]{color-scheme:light;background:#fff;color:#111827}body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:42rem;padding:0 1rem}a{color:inherit}</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, /localStorage\.getItem\('theme'\)/)
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
- // PORT and HOST are the names hosting platforms set; ACRYLBLENDS_PORT and ACRYLBLENDS_HOST win when both are given.
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
- // PORT and HOST are the names hosting platforms set; ACRYLBLENDS_PORT and ACRYLBLENDS_HOST win when both are given.
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})`)