acrylblends 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,17 +9,24 @@
9
9
  * - Only the app's own page may ask (a same-origin request carrying the token), one run at a time, and every ask is written to `.acryl/audit/agent-panel.jsonl`.
10
10
  * - Development only: the plugin does nothing with `NODE_ENV=production` or `ACRYL_DOOR=off`.
11
11
  */
12
- import { randomBytes } from 'node:crypto'
12
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
13
13
  import { appendFileSync, mkdirSync } from 'node:fs'
14
14
  import { dirname, join } from 'node:path'
15
15
  import type { Context } from '@deepseek-ai/cordis'
16
16
  import { clientModules } from '../acryl-node-server/src/client-modules.ts'
17
- import { streamSSE } from '../acryl-router/src/helpers/index.ts'
18
- import { Acryl } from '../acryl-router/src/index.ts'
17
+ import { streamSSE, type UpgradeWebSocket } from '../acryl-router/src/helpers/index.ts'
18
+ import { Acryl, type MiddlewareHandler } from '../acryl-router/src/index.ts'
19
+ import { loopbackAuthority } from '../acryl-router/src/middleware/index.ts'
19
20
  import type {} from '../agent/index.ts'
20
21
  import type {} from '../extensions/index.ts'
21
22
  import type { ChatDriver } from './drivers.ts'
22
- import { agentDriver, availableHeadless, type HeadlessName, headlessDriver } from './headless.ts'
23
+ import {
24
+ agentDriver,
25
+ availableHeadless,
26
+ type HeadlessName,
27
+ headlessDriver,
28
+ whyNotChat,
29
+ } from './headless.ts'
23
30
  import { provideDrivers } from './registry.ts'
24
31
 
25
32
  export type {
@@ -117,25 +124,9 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
117
124
  if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
118
125
  return c.json({ error: "only the app's own page may ask" }, 403)
119
126
  const body = (await c.req.json().catch(() => ({}))) as { message?: unknown; driver?: unknown }
120
- const message = typeof body.message === 'string' ? body.message.trim() : ''
121
- const driverName = typeof body.driver === 'string' ? body.driver : 'agent'
122
- if (message === '' || message.length > 8000)
123
- return c.json({ error: 'write a message of at most 8000 characters' }, 400)
124
- const driver = registry.get(driverName)
125
- if (driver?.kind !== 'chat')
126
- return c.json(
127
- {
128
- error: `the driver "${driverName}" is not available here (${registry
129
- .list()
130
- .filter((d) => d.kind === 'chat')
131
- .map((d) => d.name)
132
- .join(', ')})`,
133
- },
134
- 400,
135
- )
136
- if (current !== undefined && !current.ended)
137
- return c.json({ error: 'a run is already going; wait for it to end' }, 409)
138
- const run = startRun(driverName, message, driver)
127
+ const checked = checkAsk(body.message, body.driver)
128
+ if ('error' in checked) return c.json({ error: checked.error }, checked.status)
129
+ const run = startRun(checked.driverName, checked.message, checked.driver)
139
130
  return follow(c, run, false)
140
131
  })
141
132
  // 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.
@@ -151,11 +142,40 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
151
142
  return c.json({ error: "only the app's own page may read the run" }, 403)
152
143
  return current === undefined ? c.json({ run: null }) : follow(c, current, true)
153
144
  })
145
+ /** What `/ask` and the socket's `ask` both check: a message of a sensible size, a chat driver that is here, and no run going. */
146
+ const checkAsk = (
147
+ rawMessage: unknown,
148
+ rawDriver: unknown,
149
+ ):
150
+ | { message: string; driverName: string; driver: ChatDriver }
151
+ | { error: string; status: 400 | 409 } => {
152
+ const message = typeof rawMessage === 'string' ? rawMessage.trim() : ''
153
+ const driverName = typeof rawDriver === 'string' ? rawDriver : 'agent'
154
+ if (message === '' || message.length > 8000)
155
+ return { error: 'write a message of at most 8000 characters', status: 400 }
156
+ const driver = registry.get(driverName)
157
+ if (driver?.kind !== 'chat') {
158
+ const chat = registry
159
+ .list()
160
+ .filter((d) => d.kind === 'chat')
161
+ .map((d) => d.name)
162
+ .join(', ')
163
+ const why = whyNotChat(driverName, driver?.kind)
164
+ return {
165
+ error: `the driver "${driverName}" is not available here${why === undefined ? '' : `: ${why}`} (chat drivers: ${chat})`,
166
+ status: 400,
167
+ }
168
+ }
169
+ if (current !== undefined && !current.ended)
170
+ return { error: 'a run is already going; wait for it to end', status: 409 }
171
+ return { message, driverName, driver }
172
+ }
154
173
  /** One run of an agent: its events as they came, and who is following. It lives in the app, not in a page. */
155
174
  interface Run {
156
175
  readonly id: number
157
176
  readonly driver: string
158
177
  readonly message: string
178
+ readonly startedAt: number
159
179
  readonly events: Array<{ event: string; data: unknown }>
160
180
  readonly followers: Set<(item: { event: string; data: unknown }) => void>
161
181
  readonly cancel: AbortController
@@ -170,6 +190,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
170
190
  id: runs,
171
191
  driver: driverName,
172
192
  message,
193
+ startedAt: Date.now(),
173
194
  events: [],
174
195
  followers: new Set(),
175
196
  cancel: new AbortController(),
@@ -205,33 +226,36 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
205
226
  })()
206
227
  return run
207
228
  }
208
- /** Follow a run as server-sent events: a `run` event (what it is, whether it already ended), the events so far, then the new ones until it ends. Following does not keep it alive. */
209
- const follow = (c: Parameters<typeof streamSSE>[0], run: Run, announce: boolean): Response =>
210
- streamSSE(c, async (stream) => {
211
- if (announce)
212
- await stream.writeSSE({
213
- event: 'run',
214
- data: JSON.stringify({
215
- id: run.id,
216
- driver: run.driver,
217
- message: run.message,
218
- ended: run.ended,
219
- endedAt: run.endedAt ?? null,
220
- }),
221
- })
222
- const queue: Array<{ event: string; data: unknown }> = [...run.events]
223
- let wake: (() => void) | undefined
224
- const follower = (item: { event: string; data: unknown }): void => {
225
- queue.push(item)
226
- wake?.()
227
- }
228
- if (!run.ended) run.followers.add(follower)
229
- let open = true
230
- stream.onAbort(() => {
231
- open = false
232
- run.followers.delete(follower)
233
- wake?.()
229
+ /**
230
+ * Follow a run into `write`: a `run` event (what it is, whether it already ended) when `announce`, the events so far, then the new ones until it ends. Following does not keep the
231
+ * run alive. `detach` stops following (the page went away); `done` settles when the run ended or the following was detached.
232
+ */
233
+ const pump = (
234
+ run: Run,
235
+ announce: boolean,
236
+ write: (event: string, data: unknown) => void | Promise<void>,
237
+ ): { done: Promise<void>; detach(): void } => {
238
+ const queue: Array<{ event: string; data: unknown }> = [...run.events]
239
+ if (announce)
240
+ queue.unshift({
241
+ event: 'run',
242
+ data: {
243
+ id: run.id,
244
+ driver: run.driver,
245
+ message: run.message,
246
+ startedAt: run.startedAt,
247
+ ended: run.ended,
248
+ endedAt: run.endedAt ?? null,
249
+ },
234
250
  })
251
+ let wake: (() => void) | undefined
252
+ const follower = (item: { event: string; data: unknown }): void => {
253
+ queue.push(item)
254
+ wake?.()
255
+ }
256
+ if (!run.ended) run.followers.add(follower)
257
+ let open = true
258
+ const done = (async () => {
235
259
  try {
236
260
  while (open) {
237
261
  const item = queue.shift()
@@ -244,11 +268,130 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
244
268
  continue
245
269
  }
246
270
  if (item.event === 'end') break
247
- await stream.writeSSE({ event: item.event, data: JSON.stringify(item.data) })
271
+ await write(item.event, item.data)
248
272
  }
249
273
  } finally {
250
274
  run.followers.delete(follower)
251
275
  }
276
+ })()
277
+ return {
278
+ done,
279
+ detach: () => {
280
+ open = false
281
+ run.followers.delete(follower)
282
+ wake?.()
283
+ },
284
+ }
285
+ }
286
+ /** Follow a run as server-sent events. */
287
+ const follow = (c: Parameters<typeof streamSSE>[0], run: Run, announce: boolean): Response =>
288
+ streamSSE(c, async (stream) => {
289
+ const following = pump(run, announce, (event, data) =>
290
+ stream.writeSSE({ event, data: JSON.stringify(data) }),
291
+ )
292
+ stream.onAbort(following.detach)
293
+ await following.done
252
294
  })
295
+ // The same runs over a WebSocket, which is not one of the browser's six connections per host: a page that streams its own state in two windows, with a run going, would otherwise
296
+ // queue the panel's Stop and the door's answers behind them. A WebSocket has no CORS and cannot set a header, so the Origin must be this app's own and the first frame carries the
297
+ // token. Frames: up `{ t: 'hello', token }`, then `{ t: 'ask', message, driver }`, `{ t: 'follow' }` or `{ t: 'stop' }`; down `{ e: <event>, d: <data> }`, as the stream's events.
298
+ // The server row comes after this plugin in a blend, so the WebSocket form is mounted when the server appears (and not at all without one that upgrades or without the `ws` package).
299
+ ctx.inject(['nodeServer'], (inner) => {
300
+ let upgrade: UpgradeWebSocket
301
+ try {
302
+ upgrade = (
303
+ inner as unknown as { nodeServer: { upgrades(): UpgradeWebSocket } }
304
+ ).nodeServer.upgrades()
305
+ } catch {
306
+ return
307
+ }
308
+ const ws = new Acryl()
309
+ const own: MiddlewareHandler = async (c, next) => {
310
+ const authority = loopbackAuthority(c)
311
+ const origin = c.req.header('origin')
312
+ const site = c.req.header('sec-fetch-site')
313
+ if (
314
+ authority === undefined ||
315
+ origin === undefined ||
316
+ origin !== authority.origin ||
317
+ (site !== undefined && site !== 'same-origin')
318
+ )
319
+ return c.json({ error: "only the app's own page may use the panel" }, 403)
320
+ await next()
321
+ }
322
+ const sameToken = (presented: unknown): boolean => {
323
+ if (typeof presented !== 'string') return false
324
+ const a = Buffer.from(presented)
325
+ const b = Buffer.from(token)
326
+ return a.length === b.length && timingSafeEqual(a, b)
327
+ }
328
+ ws.get(
329
+ `${PREFIX}/run/ws`,
330
+ own,
331
+ upgrade(
332
+ () => {
333
+ let authed = false
334
+ let following: { detach(): void } | undefined
335
+ let hello: NodeJS.Timeout | undefined
336
+ return {
337
+ onOpen(_event, ws) {
338
+ hello = setTimeout(() => ws.close(4401, 'no token'), 5000)
339
+ },
340
+ onMessage(event, ws) {
341
+ if (typeof event.data !== 'string') return
342
+ let message: { t?: unknown; token?: unknown; message?: unknown; driver?: unknown }
343
+ try {
344
+ message = JSON.parse(event.data) as typeof message
345
+ } catch {
346
+ return
347
+ }
348
+ if (!authed) {
349
+ if (message.t !== 'hello' || !sameToken(message.token)) {
350
+ ws.close(4401, 'token')
351
+ return
352
+ }
353
+ authed = true
354
+ if (hello !== undefined) clearTimeout(hello)
355
+ return
356
+ }
357
+ const send = (e: string, d: unknown): void => {
358
+ ws.send(JSON.stringify({ e, d }))
359
+ }
360
+ if (message.t === 'stop') {
361
+ current?.cancel.abort()
362
+ return
363
+ }
364
+ if (following !== undefined) return
365
+ const watch = (run: Run, announce: boolean): void => {
366
+ const pumped = pump(run, announce, send)
367
+ following = pumped
368
+ void pumped.done.then(() => ws.close(1000, 'run ended'))
369
+ }
370
+ if (message.t === 'ask') {
371
+ const checked = checkAsk(message.message, message.driver)
372
+ if ('error' in checked) {
373
+ send('error', { message: checked.error })
374
+ ws.close(1000, 'refused')
375
+ return
376
+ }
377
+ watch(startRun(checked.driverName, checked.message, checked.driver), false)
378
+ } else if (message.t === 'follow') {
379
+ if (current === undefined) {
380
+ send('idle', {})
381
+ ws.close(1000, 'no run')
382
+ } else watch(current, true)
383
+ }
384
+ },
385
+ onClose() {
386
+ if (hello !== undefined) clearTimeout(hello)
387
+ following?.detach()
388
+ },
389
+ }
390
+ },
391
+ { textOnly: true },
392
+ ),
393
+ )
394
+ inner.router.route('/', ws)
395
+ })
253
396
  ctx.router.route('/', app)
254
397
  }
@@ -17,6 +17,7 @@ export interface WindowPort {
17
17
  isFocused(): boolean
18
18
  isMinimized(): boolean
19
19
  getSize(): number[]
20
+ getPosition(): number[]
20
21
  restore(): void
21
22
  focus(): void
22
23
  close(): void
@@ -68,6 +69,8 @@ export type ShellResult =
68
69
  focused: boolean
69
70
  width: number
70
71
  height: number
72
+ x?: number
73
+ y?: number
71
74
  }[]
72
75
  }
73
76
  | { readonly items: readonly { path: string; label: string; enabled: boolean; ends?: boolean }[] }
@@ -133,6 +136,7 @@ export function handleShellCall(request: ShellRequest, desktop: DesktopPort): Sh
133
136
  return {
134
137
  windows: desktop.windows().map((window) => {
135
138
  const [width = 0, height = 0] = window.getSize()
139
+ const [x = 0, y = 0] = window.getPosition()
136
140
  return {
137
141
  id: window.id,
138
142
  title: window.getTitle(),
@@ -140,6 +144,8 @@ export function handleShellCall(request: ShellRequest, desktop: DesktopPort): Sh
140
144
  focused: window.isFocused(),
141
145
  width,
142
146
  height,
147
+ x,
148
+ y,
143
149
  }
144
150
  }),
145
151
  }
@@ -9,7 +9,7 @@
9
9
  import { mkdirSync, readFileSync } from 'node:fs'
10
10
  import { join } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
- import { app, BrowserWindow, Menu, type MenuItem, shell } from 'electron'
12
+ import { app, BrowserWindow, Menu, MenuItem, screen, shell } from 'electron'
13
13
  import type { App } from '../../src/app.ts'
14
14
  import '../acryl-node-server/src/register.ts'
15
15
  import { selectInstance } from '../instance/index.ts'
@@ -20,9 +20,10 @@ import {
20
20
  startDoorShell,
21
21
  type WindowPort,
22
22
  } from './door-shell.ts'
23
+ import { hasSettings, settingsPlacement } from './menu.ts'
23
24
  import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
24
25
  import { resolveDesktopUserDataOverride } from './user-data.ts'
25
- import { windowOptions } from './window.ts'
26
+ import { cascade, windowOptions } from './window.ts'
26
27
 
27
28
  const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
28
29
  const chosen = selectInstance({ projectRoot, env: process.env })
@@ -91,7 +92,26 @@ if (!app.requestSingleInstanceLock()) {
91
92
  const origin = new URL(url).origin
92
93
  /** One window on a path of the app. Navigation stays on the app's own server; any other address opens in the system browser. The user agent names the window to the door (`AcrylWindow/<id>`), so an agent can drive it with `--window <id>`. */
93
94
  const createWindow = (path: string): BrowserWindow => {
94
- const created = new BrowserWindow(windowOptions({ title: chosen.name }))
95
+ // A window opened while another is open sits a step down and to the right of it, not exactly on top.
96
+ const beside =
97
+ BrowserWindow.getFocusedWindow() ??
98
+ BrowserWindow.getAllWindows().find((other) => !other.isDestroyed())
99
+ const where = (() => {
100
+ if (beside === undefined) return {}
101
+ const bounds = beside.getBounds()
102
+ // The places of the windows that are open: a new window never goes exactly where one already is.
103
+ const taken = BrowserWindow.getAllWindows()
104
+ .filter((other) => !other.isDestroyed())
105
+ .map((other) => other.getPosition())
106
+ .map(([x = 0, y = 0]) => ({ x, y }))
107
+ return cascade(
108
+ bounds,
109
+ screen.getDisplayMatching(bounds).workArea,
110
+ { width: 1100, height: 760 },
111
+ taken,
112
+ )
113
+ })()
114
+ const created = new BrowserWindow(windowOptions({ title: chosen.name, ...where }))
95
115
  created.webContents.setUserAgent(
96
116
  `${created.webContents.getUserAgent()} AcrylWindow/${String(created.id)}`,
97
117
  )
@@ -123,6 +143,7 @@ if (!app.requestSingleInstanceLock()) {
123
143
  isFocused: () => candidate.isFocused(),
124
144
  isMinimized: () => candidate.isMinimized(),
125
145
  getSize: () => candidate.getSize(),
146
+ getPosition: () => candidate.getPosition(),
126
147
  restore: () => candidate.restore(),
127
148
  focus: () => candidate.focus(),
128
149
  close: () => candidate.close(),
@@ -147,6 +168,40 @@ if (!app.requestSingleInstanceLock()) {
147
168
  open: (path) => port(createWindow(path ?? startPath())),
148
169
  menu: () => (Menu.getApplicationMenu()?.items ?? []).map(menuItem),
149
170
  }
171
+ // An app that serves a /settings page gets a Settings item in the menu (Electron's own menu otherwise): it opens that page, in the window that already shows it when there is one.
172
+ const settingsServed = await fetch(new URL('/settings', url), {
173
+ headers: { accept: 'text/html' },
174
+ signal: AbortSignal.timeout(2000),
175
+ }).then(
176
+ (answer) => answer.ok,
177
+ () => false,
178
+ )
179
+ const menu = Menu.getApplicationMenu()
180
+ const placement =
181
+ menu === null || !settingsServed || hasSettings(menu.items)
182
+ ? undefined
183
+ : settingsPlacement(menu.items, process.platform)
184
+ if (menu !== null && placement !== undefined) {
185
+ const target = menu.items[placement.menu]?.submenu
186
+ const openSettings = (): void => {
187
+ const shown = live().find(
188
+ (candidate) =>
189
+ new URL(candidate.webContents.getURL() || 'about:blank', url).pathname === '/settings',
190
+ )
191
+ if (shown === undefined) createWindow('/settings')
192
+ else {
193
+ if (shown.isMinimized()) shown.restore()
194
+ shown.focus()
195
+ }
196
+ }
197
+ target?.insert(
198
+ placement.at,
199
+ new MenuItem({ label: 'Settings...', accelerator: 'CmdOrCtrl+,', click: openSettings }),
200
+ )
201
+ if (placement.separatorAfter)
202
+ target?.insert(placement.at + 1, new MenuItem({ type: 'separator' }))
203
+ Menu.setApplicationMenu(menu)
204
+ }
150
205
  stopDoorShell = startDoorShell(projectRoot, { desktop, log: console.log })
151
206
  })
152
207
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The desktop app's Settings menu item. An app that serves a `/settings` page gets a "Settings..." item that opens it in a window (Cmd+, on macOS, Ctrl+, elsewhere); an app without
3
+ * the page gets nothing. Where the item goes is decided here, from the labels of the application menu Electron built, so it is tested without Electron.
4
+ */
5
+
6
+ /** The part of an Electron menu item this file reads. */
7
+ export interface MenuNode {
8
+ readonly label: string
9
+ readonly role?: string | undefined
10
+ readonly type?: string | undefined
11
+ readonly submenu?: { readonly items: readonly MenuNode[] } | null | undefined
12
+ }
13
+
14
+ export interface Placement {
15
+ /** Index of the top-level menu that gets the item. */
16
+ readonly menu: number
17
+ /** Index inside that menu to insert at. */
18
+ readonly at: number
19
+ /** Whether a separator goes after the item (so it sits in a group of its own). */
20
+ readonly separatorAfter: boolean
21
+ }
22
+
23
+ /**
24
+ * macOS puts Settings in the application menu, under About; elsewhere it is the first item of the File menu. Returns nothing when the menu has neither place (an app that
25
+ * replaced Electron's menu with its own keeps its own).
26
+ */
27
+ export function settingsPlacement(
28
+ items: readonly MenuNode[],
29
+ platform: NodeJS.Platform,
30
+ ): Placement | undefined {
31
+ if (platform === 'darwin') {
32
+ const submenu = items[0]?.submenu?.items
33
+ if (submenu === undefined || submenu === null) return undefined
34
+ const about = submenu.findIndex(
35
+ (item) => item.role === 'about' || /^about\b/iu.test(item.label),
36
+ )
37
+ // Under About and the separator after it; first when there is no About.
38
+ const separator =
39
+ about === -1
40
+ ? -1
41
+ : submenu.findIndex((item, index) => index > about && item.type === 'separator')
42
+ return {
43
+ menu: 0,
44
+ at: separator === -1 ? (about === -1 ? 0 : about + 1) : separator + 1,
45
+ separatorAfter: true,
46
+ }
47
+ }
48
+ const file = items.findIndex((item) => /^&?file$/iu.test(item.label) || item.role === 'fileMenu')
49
+ if (file === -1 || items[file]?.submenu == null) return undefined
50
+ return { menu: file, at: 0, separatorAfter: true }
51
+ }
52
+
53
+ /** Whether a menu already has a Settings item (so a restart that builds the menu twice adds it once). */
54
+ export function hasSettings(items: readonly MenuNode[]): boolean {
55
+ return items.some(
56
+ (item) =>
57
+ /^settings\b|^preferences\b/iu.test(item.label) ||
58
+ (item.submenu?.items !== undefined && hasSettings(item.submenu.items)),
59
+ )
60
+ }
@@ -1,23 +1,83 @@
1
1
  /** The one window of a desktop app: the operating system's frame around a page the app's own server serves, with the renderer locked down. */
2
- import type { BrowserWindowConstructorOptions } from 'electron'
3
2
 
4
3
  export interface WindowSpec {
5
4
  readonly title: string
6
5
  readonly width?: number
7
6
  readonly height?: number
7
+ /** Where the window's top left corner goes; Electron centers the window when these are left out. */
8
+ readonly x?: number
9
+ readonly y?: number
10
+ }
11
+
12
+ export interface Rect {
13
+ readonly x: number
14
+ readonly y: number
15
+ readonly width: number
16
+ readonly height: number
17
+ }
18
+
19
+ /** How far each new window sits from the one it was opened from. */
20
+ export const CASCADE_STEP = 32
21
+
22
+ /**
23
+ * Where a new window goes so that it does not cover another one exactly. The first free place of: a step down and to the right of `from`, and again, while the window still fits on the
24
+ * screen; then the same steps from the top left of the work area (a row of windows cascades, then starts again); and, on a screen no bigger than the window, the steps from `from` even
25
+ * though the window runs past the edge (two windows at one place cannot be told apart). A place is free when no window in `taken` is there.
26
+ */
27
+ export function cascade(
28
+ from: Rect,
29
+ work: Rect,
30
+ size: { width: number; height: number },
31
+ taken: readonly { x: number; y: number }[] = [],
32
+ ): { x: number; y: number } {
33
+ const free = (place: { x: number; y: number }): boolean =>
34
+ !taken.some((other) => other.x === place.x && other.y === place.y)
35
+ const fits = (place: { x: number; y: number }): boolean =>
36
+ place.x + size.width <= work.x + work.width && place.y + size.height <= work.y + work.height
37
+ const steps = (origin: { x: number; y: number }): { x: number; y: number }[] =>
38
+ Array.from({ length: 40 }, (_, at) => ({
39
+ x: origin.x + CASCADE_STEP * (at + 1),
40
+ y: origin.y + CASCADE_STEP * (at + 1),
41
+ }))
42
+ const fromHere = steps(from)
43
+ const fromCorner = [{ x: work.x, y: work.y }, ...steps(work)]
44
+ return (
45
+ fromHere.find((place) => fits(place) && free(place)) ??
46
+ fromCorner.find((place) => fits(place) && free(place)) ??
47
+ fromHere.find(free) ??
48
+ fromHere[0] ?? { x: from.x + CASCADE_STEP, y: from.y + CASCADE_STEP }
49
+ )
8
50
  }
9
51
 
10
52
  /**
11
53
  * Secure BrowserWindow options. The settings are ACRYL Desktop's: context isolation on, no Node in the page, the sandbox on, web security on; the window stays
12
54
  * hidden until the page has painted (`show: false`, shown on `ready-to-show`).
13
55
  */
14
- export function windowOptions(spec: WindowSpec): BrowserWindowConstructorOptions {
56
+ export interface WindowOptions {
57
+ readonly title: string
58
+ readonly width: number
59
+ readonly height: number
60
+ readonly minWidth: number
61
+ readonly minHeight: number
62
+ readonly x?: number
63
+ readonly y?: number
64
+ readonly show: boolean
65
+ readonly webPreferences: {
66
+ readonly contextIsolation: boolean
67
+ readonly nodeIntegration: boolean
68
+ readonly sandbox: boolean
69
+ readonly webSecurity: boolean
70
+ }
71
+ }
72
+
73
+ export function windowOptions(spec: WindowSpec): WindowOptions {
15
74
  return {
16
75
  title: spec.title,
17
76
  width: spec.width ?? 1100,
18
77
  height: spec.height ?? 760,
19
78
  minWidth: 480,
20
79
  minHeight: 360,
80
+ ...(spec.x === undefined || spec.y === undefined ? {} : { x: spec.x, y: spec.y }),
21
81
  show: false,
22
82
  webPreferences: {
23
83
  contextIsolation: true,
@@ -7,6 +7,8 @@ and an optional `check(ctx)` that receives the plugin's own context (so `ctx.rou
7
7
 
8
8
  **Write an extension, not a row.** A feature you build while the app runs is a folder `plugins/<name>/` installed with `.acryl/bin/acryl plugin add plugins/<name>` (the `extensions` plugin loads it again at every start). Add a row to `blend.yaml` and an entry to the `registry` of `src/app.ts` only when the person asks for a plugin that is part of the app itself. Both kinds may import the framework by name (`acrylblends`, `acrylblends/body-limit`, `acryl-router`); a row's relative imports of its sibling plugins also work.
9
9
 
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. When a person asks for settings, build them as a page at `/settings`, so `acryl window open --path /settings` opens them in every app. Without a desktop app these answer "no desktop window is attached; run npm run desktop".
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
+
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.
11
13
 
12
14
  **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.
@@ -19,6 +19,15 @@ const DOOR_TAG =
19
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
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. */
23
+ 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>"
27
+
28
+ /** `/settings`: the one setting the template has, the appearance. The desktop app's Settings menu item opens this page. */
29
+ 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>`
30
+
22
31
  export const routes = {
23
32
  name: 'routes',
24
33
  inject: ['router'],
@@ -37,11 +46,12 @@ export const routes = {
37
46
  .get('/', (c) =>
38
47
  (c.req.header('accept') ?? '').includes('text/html')
39
48
  ? c.html(
40
- `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title></head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). Ask it for a feature in the agent panel on this page (or with <code>POST /ask</code>).</p>${DOOR_TAG}</body></html>`,
49
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title>${APPEARANCE_STYLE}${APPEARANCE_SCRIPT}</head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). Ask it for a feature in the agent panel on this page (or with <code>POST /ask</code>).</p>${DOOR_TAG}</body></html>`,
41
50
  )
42
51
  : c.json({ app: APP_NAME, ok: true }),
43
52
  )
44
53
  .get('/health', (c) => c.json({ ok: true }))
54
+ .get('/settings', (c) => c.html(settingsPage))
45
55
  ctx.router.route('/', base)
46
56
  // The routes that give a visitor the agent: mounted only while the agent, the extensions and the tasks run (see the header).
47
57
  ctx.inject(['agent', 'extensions', 'tasks'], (inner) => {
@@ -144,3 +144,23 @@ test('outside production nothing changes: the agent answers and the extensions r
144
144
  assert.equal((await app.root.router.request('/extensions')).status, 200)
145
145
  assert.deepEqual(warnings, [])
146
146
  }))
147
+
148
+ test('the app has a /settings page with the appearance setting, and the home page applies it', () =>
149
+ inEnvironment({ NODE_ENV: 'test', ACRYL_PRODUCTION_KEEP: undefined }, async (app) => {
150
+ const settings = await app.root.router.request('/settings', {
151
+ headers: { accept: 'text/html' },
152
+ })
153
+ assert.equal(settings.status, 200)
154
+ const page = await settings.text()
155
+ assert.match(page, /<h1>Settings<\/h1>/)
156
+ assert.match(page, /<select id="theme"/)
157
+ for (const option of ['System', 'Light', 'Dark'])
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.
160
+ const home = await (
161
+ await app.root.router.request('/', { headers: { accept: 'text/html' } })
162
+ ).text()
163
+ assert.match(home, /localStorage\.getItem\('theme'\)/)
164
+ // ... without leaving a global behind for the page's own code to collide with.
165
+ assert.doesNotMatch(home, /<script>\s*(?:var|let|const) /)
166
+ }))