acrylblends 1.0.3 → 1.0.4

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 (33) hide show
  1. package/docs/framework/workshop/README.md +3 -3
  2. package/docs/framework/workshop/facilitator.md +1 -0
  3. package/lib/{audit-BXnYsLge.d.ts → audit-D5z9uwOP.d.ts} +14 -1
  4. package/lib/cordis/door.d.ts +8 -2
  5. package/lib/cordis/door.js +88 -12
  6. package/lib/cordis/inspect.js +1 -1
  7. package/lib/cordis/router-http.d.ts +1 -1
  8. package/lib/cordis/router.d.ts +1 -1
  9. package/lib/cordis/router.js +1 -1
  10. package/lib/cordis/state.js +1 -1
  11. package/lib/door/host.d.ts +2 -2
  12. package/lib/door/host.js +1 -1
  13. package/lib/door/page.d.ts +12 -1
  14. package/lib/door/page.js +21 -0
  15. package/lib/door/protocol.d.ts +2 -2
  16. package/lib/door/protocol.js +19 -3
  17. package/lib/{index-D4nVR3wu.d.ts → index-DMOtA-NJ.d.ts} +7 -1
  18. package/lib/{protocol-BizVt4J6.d.ts → protocol-C37gPePD.d.ts} +9 -2
  19. package/lib/{snapshot-text-DmE0YfeZ.js → snapshot-text-BLz8nlSc.js} +49 -1
  20. package/lib/{src-2Z4mJg0x.js → src-dy3Fk9tA.js} +16 -0
  21. package/package.json +1 -1
  22. package/plugins/acryl-door/src/cli/acryl.ts +5 -3
  23. package/plugins/acryl-door/src/host/channel.ts +36 -0
  24. package/plugins/acryl-door/src/host/door.ts +26 -4
  25. package/plugins/acryl-door/src/host/shim.ts +23 -0
  26. package/plugins/acryl-door/src/host/verbs.ts +4 -3
  27. package/plugins/acryl-door/src/index.ts +26 -3
  28. package/plugins/acryl-door/src/page/driver/driver.ts +22 -1
  29. package/plugins/acryl-door/src/page/protocol.ts +13 -2
  30. package/plugins/acryl-router/src/service.ts +19 -1
  31. package/plugins/agent-panel/client/main.ts +63 -34
  32. package/plugins/agent-panel/index.ts +115 -25
  33. package/shared/DEFAULTS.md +4 -0
@@ -15,8 +15,8 @@ import { dirname, join, resolve } from 'node:path'
15
15
  import { fileURLToPath } from 'node:url'
16
16
  import { readDoorFile, STATE_DIR, DOOR_FILE } from '../host/door-file.ts'
17
17
 
18
- const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait'])
19
- const VALUED = new Set(['--dir', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--tool', '--args'])
18
+ const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'])
19
+ const VALUED = new Set(['--dir', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--tool', '--args', '--path'])
20
20
  const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--help'])
21
21
 
22
22
  export class UsageError extends Error {}
@@ -36,6 +36,8 @@ const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
36
36
  act select --ref <r> --option <o>
37
37
  act press --key <key> [--ref <r>]
38
38
  act scroll --direction up|down|left|right [--ref <r>] [--amount <px>]
39
+ act reload
40
+ act navigate --path <path of the app, e.g. /demo>
39
41
  act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
40
42
  plugin list | plugin add <folder> | plugin remove <name> | plugin reload
41
43
  tool list | tool call --tool <name> [--args '<json>']
@@ -90,7 +92,7 @@ export function parseInvocation(argv: readonly string[]): Invocation {
90
92
  return make('act', {
91
93
  op: second,
92
94
  ...pick('--ref', 'ref'), ...pick('--text', 'text'), ...pick('--option', 'option'), ...pick('--key', 'key'),
93
- ...pick('--direction', 'direction'), ...pick('--role', 'role'), ...pick('--name', 'name'),
95
+ ...pick('--direction', 'direction'), ...pick('--role', 'role'), ...pick('--name', 'name'), ...pick('--path', 'path'),
94
96
  ...(amount === undefined ? {} : { amount }),
95
97
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
96
98
  ...(set.has('--submit') ? { submit: true } : {}),
@@ -45,6 +45,8 @@ export class UiChannel {
45
45
  private nextId = 1
46
46
  private clock = 0
47
47
  private closed = false
48
+ /** Callers waiting for a page to (re)connect: each is told once, by the next stream that attaches. */
49
+ private readonly attachWaiters = new Set<() => void>()
48
50
 
49
51
  /**
50
52
  * Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
@@ -55,6 +57,7 @@ export class UiChannel {
55
57
  if (previous !== undefined) this.drop(previous, true)
56
58
  const connection: Connection = { stream, windowId, focused, lastActive: ++this.clock }
57
59
  this.connections.set(windowId, connection)
60
+ for (const waiter of [...this.attachWaiters]) waiter()
58
61
  return () => { if (this.connections.get(windowId) === connection) this.drop(connection, false) }
59
62
  }
60
63
 
@@ -109,6 +112,39 @@ export class UiChannel {
109
112
  })
110
113
  }
111
114
 
115
+ /**
116
+ * A promise for the next time a page connects (a page that reloaded or moved opens a new stream). Ask for it BEFORE the call that makes the page leave, or a fast page is missed.
117
+ * @returns whether a page connected within `timeoutMs`.
118
+ */
119
+ expectAttach(timeoutMs: number, count = 1): Promise<boolean> {
120
+ return new Promise((resolve) => {
121
+ let left = count
122
+ const done = (value: boolean): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); resolve(value) }
123
+ const waiter = (): void => { left -= 1; if (left <= 0) done(true) }
124
+ const timer = setTimeout(() => { done(false) }, timeoutMs)
125
+ this.attachWaiters.add(waiter)
126
+ })
127
+ }
128
+
129
+ /**
130
+ * Ask every connected page to reload (the app just changed under them) and wait, up to `timeoutMs`, for them to come back. Best effort: a page that does not come back
131
+ * (it was closed, or it moved to a page without the door) is not an error here.
132
+ * @returns how many pages were asked.
133
+ */
134
+ async reloadAll(timeoutMs = 8000): Promise<number> {
135
+ const targets = this.byRecency()
136
+ if (this.closed || targets.length === 0) return 0
137
+ const back = this.expectAttach(timeoutMs, targets.length)
138
+ for (const target of targets) {
139
+ const id = this.nextId
140
+ this.nextId += 1
141
+ // No call is waiting for the answer: the page answers by POST and the Host ignores an id it does not know.
142
+ try { target.stream.send(JSON.stringify({ t: 'call', id, request: { op: 'reload' } } satisfies ChannelToPage)) } catch { /* that page is gone */ }
143
+ }
144
+ await back
145
+ return targets.length
146
+ }
147
+
112
148
  /** Close every page's stream and settle every pending call as `unloaded`. */
113
149
  close(): void {
114
150
  this.closed = true
@@ -50,6 +50,10 @@ export interface DoorDeps {
50
50
  readonly tools: () => ToolsPort | undefined
51
51
  /** The app tools the door offers; anything else is refused before it reaches the registry. */
52
52
  readonly expose: readonly string[]
53
+ /** Reload the open pages after a plugin was added, removed or reloaded, so the page an agent looks at next is the new one. Default: true. */
54
+ readonly reloadOnChange?: boolean
55
+ /** How long a reload or a move may take before the new page has connected again (tests shorten it). Default 15 s. */
56
+ readonly pageBackMs?: number
53
57
  }
54
58
 
55
59
  /** What one verb answered: the value for `--json` and programs, the text for an agent reading a terminal. */
@@ -61,6 +65,9 @@ export interface VerbAnswer {
61
65
  /** The files an agent should read first, when the app has them. */
62
66
  const SELF_KNOWLEDGE = ['AGENTS.md', 'CLAUDE.md', '.agents/skills', '.claude/skills', 'docs', 'plugins']
63
67
 
68
+ /** How long a reload or a move may take before the new page has connected to the door again. */
69
+ const PAGE_BACK_MS = 15_000
70
+
64
71
  export class Door {
65
72
  private readonly deps: DoorDeps
66
73
 
@@ -81,7 +88,7 @@ export class Door {
81
88
  const result = await this.act(input, signal)
82
89
  return { value: result, text: `done${result.target === undefined ? '' : `: ${result.target.role} "${result.target.name}"`}${result.detail === undefined ? '' : ` (${result.detail})`}; look again before the next act` }
83
90
  }
84
- case 'plugin-list': return this.plugin(extensions => extensions.list())
91
+ case 'plugin-list': return this.plugin(extensions => extensions.list(), false)
85
92
  case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
86
93
  case 'plugin-remove': {
87
94
  const name = input.name
@@ -127,9 +134,21 @@ export class Door {
127
134
  return this.callPage('look', request, signal) as Promise<UiSnapshot>
128
135
  }
129
136
 
130
- act(input: Record<string, unknown>, signal: AbortSignal): Promise<UiActionResult> {
137
+ async act(input: Record<string, unknown>, signal: AbortSignal): Promise<UiActionResult> {
131
138
  if (input.op === 'snapshot') throw new DoorError('invalid', 'use look to see the page')
132
- return this.callPage(`act ${String(input.op)}`, this.parse(input), signal) as Promise<UiActionResult>
139
+ const request = this.parse(input)
140
+ if (request.op !== 'reload' && request.op !== 'navigate') return await this.callPage(`act ${String(input.op)}`, request, signal) as UiActionResult
141
+ // The page is replaced: wait for the new one to connect, so the next look sees it. The page may drop before it has answered; that is the reload working.
142
+ const back = this.deps.channel.expectAttach(this.deps.pageBackMs ?? PAGE_BACK_MS)
143
+ let answer: UiActionResult
144
+ try {
145
+ answer = await this.callPage(`act ${request.op}`, request, signal) as UiActionResult
146
+ } catch (cause) {
147
+ if (!(cause instanceof UiControlError && cause.code === 'disconnected')) { void back; throw cause }
148
+ answer = { ok: true, detail: request.op === 'reload' ? 'reloading' : `going to ${request.path}` }
149
+ }
150
+ if (!await back) throw new UiControlError('timeout', request.op === 'reload' ? 'the page did not come back after the reload' : `the page did not come back after going to ${request.path}: is there a page at that path, and does it load the door's script?`)
151
+ return { ...answer, detail: `${answer.detail ?? 'done'}; the page is back: look again` }
133
152
  }
134
153
 
135
154
  private parse(input: Record<string, unknown>): UiRequest {
@@ -156,10 +175,13 @@ export class Door {
156
175
  }
157
176
  }
158
177
 
159
- private async plugin(run: (extensions: ExtensionsPort) => unknown): Promise<VerbAnswer> {
178
+ private async plugin(run: (extensions: ExtensionsPort) => unknown, mutates = true): Promise<VerbAnswer> {
160
179
  const extensions = this.deps.extensions()
161
180
  if (extensions === undefined) throw new DoorError('unavailable', 'this app has no extensions plugin, so plugins cannot be installed while it runs')
162
181
  const value = await run(extensions)
182
+ // The app changed under the open pages: reload them (not when the plugin was already as it should be), and answer once they are back.
183
+ const unchanged = typeof value === 'object' && value !== null && (value as { action?: unknown }).action === 'unchanged'
184
+ if (mutates && !unchanged && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll()
163
185
  return { value, text: JSON.stringify(value, null, 2) }
164
186
  }
165
187
 
@@ -7,6 +7,7 @@
7
7
  * app is running in the folder (exit 3). It carries no secret; the client reads `door.json` at each call.
8
8
  */
9
9
 
10
+ import { spawnSync } from 'node:child_process'
10
11
  import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
11
12
  import { join } from 'node:path'
12
13
  import { ensureStateDir } from './door-file.ts'
@@ -25,6 +26,28 @@ export interface ShimTarget {
25
26
  readonly electron: boolean
26
27
  }
27
28
 
29
+ /** The oldest Node the client runs on (the framework's own floor). */
30
+ const MIN_NODE = [22, 19]
31
+
32
+ /**
33
+ * A real Node for the command, when the app runs inside Electron: Electron can run the client as Node, but on macOS every such call prints a code-signing complaint on stderr,
34
+ * which lands in an agent's transcript. A `node` on the PATH that is new enough is used instead; without one, Electron runs the client. `find` is replaceable for tests.
35
+ */
36
+ export function realNode(find: () => { path: string; version: string } | undefined = findNodeOnPath): string | undefined {
37
+ const found = find()
38
+ if (found === undefined) return undefined
39
+ const [major = 0, minor = 0] = found.version.replace(/^v/u, '').split('.').map(Number)
40
+ return major > MIN_NODE[0]! || (major === MIN_NODE[0] && minor >= MIN_NODE[1]!) ? found.path : undefined
41
+ }
42
+
43
+ function findNodeOnPath(): { path: string; version: string } | undefined {
44
+ const located = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['node'], { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined } })
45
+ const path = located.status === 0 ? located.stdout.split(/\r?\n/u).find((line) => line.trim() !== '')?.trim() : undefined
46
+ if (path === undefined) return undefined
47
+ const asked = spawnSync(path, ['--version'], { encoding: 'utf8' })
48
+ return asked.status === 0 ? { path, version: asked.stdout.trim() } : undefined
49
+ }
50
+
28
51
  /** A word for `sh`: single-quoted, with each `'` closed, escaped and reopened. */
29
52
  function shWord(value: string): string {
30
53
  return `'${value.replaceAll('\'', '\'\\\'\'')}'`
@@ -47,12 +47,13 @@ export const VERB_SPECS: Readonly<Record<Verb, VerbSpec>> = {
47
47
  },
48
48
  },
49
49
  'act': {
50
- usage: 'act click|type|select|press|scroll|wait ...: do one thing on the page (refs from the latest look)',
51
- description: 'Do one thing on the page: click, type, select, press a key, scroll, or wait for text or a control. Look again before the next act.',
50
+ usage: 'act click|type|select|press|scroll|wait|reload|navigate ...: do one thing on the page (refs from the latest look)',
51
+ description: 'Do one thing on the page: click, type, select, press a key, scroll, wait for text or a control, reload the page, or navigate to another path of the app. Look again before the next act.',
52
52
  arguments: {
53
53
  type: 'object',
54
54
  properties: {
55
- op: { type: 'string', enum: ['click', 'type', 'select', 'press', 'scroll', 'wait'] },
55
+ op: { type: 'string', enum: ['click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'] },
56
+ path: { type: 'string', description: 'navigate: a path of this app, e.g. /demo (the page must load the door script to be controlled afterwards)' },
56
57
  ref,
57
58
  text: { type: 'string', description: 'type: what to type; wait: the text to wait for' },
58
59
  submit: { type: 'boolean', description: 'type: press Enter after' },
@@ -23,7 +23,7 @@ import { DOOR_PREFIX, privateApi } from './host/http.ts'
23
23
  import { mcpRoutes } from './host/mcp-routes.ts'
24
24
  import { operatorRoutes } from './host/operator-routes.ts'
25
25
  import { pageChannelRoutes } from './host/page-routes.ts'
26
- import { writeShims, type ShimTarget } from './host/shim.ts'
26
+ import { realNode, writeShims, type ShimTarget } from './host/shim.ts'
27
27
 
28
28
  /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
29
29
  export const name = 'acryl-door'
@@ -54,10 +54,13 @@ export interface Config {
54
54
  * run it in production (a packaged desktop app whose user drives it with an agent) says so here; `ACRYL_DOOR=off` still wins.
55
55
  */
56
56
  allowInProduction?: boolean
57
+ /** Reload the open pages after a live plugin change, so an agent that built a feature sees it on the page it has open. Default: true. */
58
+ reloadOnChange?: boolean
57
59
  }
58
60
 
59
61
  export const Config: z<Config> = z.object({
60
62
  allowInProduction: z.boolean().default(false).description('Run in production too (NODE_ENV=production); ACRYL_DOOR=off still turns it off.'),
63
+ reloadOnChange: z.boolean().default(true).description('Reload the open pages after a plugin was added, removed or reloaded while the app runs.'),
61
64
  tools: z.object({
62
65
  // Not a `door_` name: those are the door's own verbs in the registry, never offered back as app tools.
63
66
  expose: z.array(z.string().pattern(/^(?!door_)[A-Za-z][A-Za-z0-9_-]{0,63}$/u)).description('App tools (from the tools registry) the door offers; none by default.'),
@@ -73,11 +76,14 @@ const CLIENT = import.meta.url.endsWith('.ts') ? join(import.meta.dirname, 'cli'
73
76
  /** How the folder's `.acryl/bin/acryl` starts the client: with the Node that runs the app, and its type-stripping flag when run from source. */
74
77
  function shimTarget(): ShimTarget {
75
78
  const fromSource = CLIENT.endsWith('.ts')
79
+ const inElectron = process.versions.electron !== undefined
80
+ // Inside Electron the command prefers a real Node (no code-signing noise on every call); without one, Electron runs the client.
81
+ const node = inElectron ? realNode() : undefined
76
82
  return {
77
- node: process.execPath,
83
+ node: node ?? process.execPath,
78
84
  nodeOptions: fromSource ? process.execArgv.filter(option => /^--experimental-(strip|transform)-types$/u.test(option)) : [],
79
85
  client: CLIENT,
80
- electron: process.versions.electron !== undefined,
86
+ electron: inElectron && node === undefined,
81
87
  }
82
88
  }
83
89
 
@@ -108,12 +114,29 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
108
114
  extensions: () => ctx.get('extensions') as unknown as ExtensionsPort | undefined,
109
115
  tools: () => ctx.get('tools') as unknown as ToolsPort | undefined,
110
116
  expose: config.tools?.expose ?? [],
117
+ reloadOnChange: config.reloadOnChange !== false,
111
118
  })
112
119
  ctx.effect(() => () => { channel.close() }, 'acryl-door: page channel')
113
120
  // One script tag for the page (`page.js`): it imports the module tree served below it.
114
121
  const pageModule = new Acryl()
115
122
  .get(`${DOOR_PREFIX}/page.js`, c => c.body(`import '${DOOR_PREFIX}/page/main.ts'\n`, 200, { 'content-type': 'text/javascript; charset=utf-8' }))
116
123
  .route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR }))
124
+ // The page script on every HTML page the app answers, whenever its plugin was mounted (the pages of a plugin that loaded at boot, before this one, included): a page an agent writes
125
+ // can be looked at and driven without anyone adding the tag. A page that already carries it (the templates' own pages) is left as it is. Development only, like the rest of the door.
126
+ const tag = `<script type="module" src="${DOOR_PREFIX}/page.js"></script>`
127
+ const addScript = async (c: { res: Response }, next: () => Promise<void>): Promise<void> => {
128
+ await next()
129
+ if (!(c.res.headers.get('content-type') ?? '').includes('text/html')) return
130
+ const html = await c.res.text()
131
+ const headers = new Headers(c.res.headers)
132
+ headers.delete('content-length')
133
+ const carries = html.includes(`${DOOR_PREFIX}/page.js`)
134
+ c.res = new Response(carries ? html : html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag, { status: c.res.status, headers })
135
+ }
136
+ const router = ctx.router as unknown as { wrap?: (middleware: typeof addScript) => unknown }
137
+ // An app whose router copy is older than this plugin has no `wrap`: the middleware then covers the routes mounted after the door, as the agent panel's does.
138
+ if (typeof router.wrap === 'function') router.wrap(addScript)
139
+ else ctx.router.route('/', new Acryl().use('*', addScript as never))
117
140
  ctx.router.route('/', privateApi(
118
141
  DOOR_PREFIX,
119
142
  pageModule,
@@ -5,7 +5,7 @@
5
5
  * shows. It touches only the document it is given, never another window and never the OS.
6
6
  */
7
7
 
8
- import { UiControlError, MUTATING_OPS, type UiRequest, type UiResult } from '../protocol.ts'
8
+ import { UiControlError, MUTATING_OPS, type UiActionResult, type UiRequest, type UiResult } from '../protocol.ts'
9
9
  import { click, pressKey, scroll, selectOption, typeText, wait, type ActionEnvironment } from './actions.ts'
10
10
  import { RefTable } from './ref-table.ts'
11
11
  import { SnapshotBuilder } from './snapshot.ts'
@@ -13,6 +13,8 @@ import { SnapshotBuilder } from './snapshot.ts'
13
13
  /** After the user touches the page, agent actions wait this long, so the user always wins. */
14
14
  const YIELD_MS = 400
15
15
  const MAX_YIELD_MS = 2000
16
+ /** How long after the answer a reload or a move starts, so the answer is on its way first. */
17
+ export const LEAVE_DELAY_MS = 150
16
18
 
17
19
  export interface DriverActivity {
18
20
  /** True from the moment a call starts until it ends. */
@@ -28,6 +30,8 @@ export interface UiDriverOptions {
28
30
  /** Milliseconds since the user last used the page (mouse, keyboard); Infinity when never. */
29
31
  readonly msSinceUserInput?: () => number
30
32
  readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>
33
+ /** Leave the page: reload it, or go to a path of the app. Default: the document's own `location`, a moment after the answer is sent. */
34
+ readonly leave?: (to: { readonly reload: true } | { readonly path: string }) => void
31
35
  }
32
36
 
33
37
  function realSleep(ms: number, signal: AbortSignal): Promise<void> {
@@ -130,9 +134,26 @@ export class UiDriver {
130
134
  case 'press': return pressKey(this.env, request)
131
135
  case 'scroll': return scroll(this.env, request)
132
136
  case 'wait': return wait(this.env, request, signal)
137
+ case 'reload': return this.leave({ reload: true }, 'reloading')
138
+ case 'navigate': return this.leave({ path: request.path }, `going to ${request.path}`)
133
139
  }
134
140
  }
135
141
 
142
+ /**
143
+ * The page is about to be replaced, so the answer is given first and the move happens a moment later (the answer is a POST that the page's own unload would cut off).
144
+ * The Host does not wait for this answer to be the last word: it waits for the page to come back.
145
+ */
146
+ private leave(to: { readonly reload: true } | { readonly path: string }, detail: string): UiActionResult {
147
+ const go = this.options.leave ?? ((target) => {
148
+ const location = this.options.document().defaultView?.location
149
+ if (location === undefined) return
150
+ if ('reload' in target) location.reload()
151
+ else location.assign(target.path)
152
+ })
153
+ setTimeout(() => { if (!this.disposed) go(to) }, LEAVE_DELAY_MS)
154
+ return { ok: true, detail }
155
+ }
156
+
136
157
  private async yieldToUser(signal: AbortSignal): Promise<void> {
137
158
  const since = this.options.msSinceUserInput
138
159
  if (since === undefined) return
@@ -7,11 +7,14 @@
7
7
  */
8
8
 
9
9
  /** The operations the driver performs on the page. */
10
- export const UI_OPS = ['snapshot', 'click', 'type', 'select', 'press', 'scroll', 'wait'] as const
10
+ export const UI_OPS = ['snapshot', 'click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'] as const
11
11
  export type UiOp = (typeof UI_OPS)[number]
12
12
 
13
13
  /** Operations that change the page, so each call needs approval. `snapshot`, `scroll` and `wait` only look. */
14
- export const MUTATING_OPS: readonly UiOp[] = ['click', 'type', 'select', 'press']
14
+ export const MUTATING_OPS: readonly UiOp[] = ['click', 'type', 'select', 'press', 'reload', 'navigate']
15
+
16
+ /** A place in the app to go to: a path on the page's own origin (`/demo`, `/notes?q=a#top`), never another site and never a `//host` address. */
17
+ export const APP_PATH = /^\/(?![/\\])[^\0\\]{0,2000}$/u
15
18
 
16
19
  export const MAX_TEXT_LENGTH = 10_000
17
20
  export const MAX_WAIT_MS = 10_000
@@ -77,6 +80,8 @@ export type UiRequest =
77
80
  | { readonly op: 'press'; readonly key: string; readonly ref?: string }
78
81
  | { readonly op: 'scroll'; readonly direction: 'up' | 'down' | 'left' | 'right'; readonly ref?: string; readonly amount?: number }
79
82
  | { readonly op: 'wait'; readonly text?: string; readonly role?: string; readonly name?: string; readonly gone?: boolean; readonly timeoutMs?: number }
83
+ | { readonly op: 'reload' }
84
+ | { readonly op: 'navigate'; readonly path: string }
80
85
 
81
86
  /** What the page did, and to which control (for the audit log and the approval prompt). */
82
87
  export interface UiActionResult {
@@ -164,6 +169,12 @@ export function parseUiRequest(value: unknown): UiRequest {
164
169
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
165
170
  }
166
171
  }
172
+ case 'reload': only([]); return { op }
173
+ case 'navigate': {
174
+ only(['path'])
175
+ if (typeof value.path !== 'string' || !APP_PATH.test(value.path)) return invalid('path must be a path of this app, such as /demo (it starts with one /, and names no other site)')
176
+ return { op, path: value.path }
177
+ }
167
178
  default: return invalid(`unknown op ${String(op)}`)
168
179
  }
169
180
  }
@@ -94,6 +94,8 @@ interface Mount {
94
94
 
95
95
  interface RouterState {
96
96
  readonly mounts: Set<Mount>
97
+ /** Middleware around every request, whenever it was added (see {@link Router.wrap}). */
98
+ readonly wrappers: Set<H>
97
99
  errorHandler: ErrorHandler | undefined
98
100
  notFoundHandler: NotFoundHandler | undefined
99
101
  /** The app composed from the live mounts; dropped on every change and composed again by the next request. */
@@ -125,7 +127,7 @@ async function drain(tasks: readonly Promise<unknown>[]): Promise<void> {
125
127
 
126
128
  export class Router extends Service {
127
129
  /** Everything that changes after construction, kept in one object so the service's live state is in one place. */
128
- private readonly state: RouterState = { mounts: new Set(), errorHandler: undefined, notFoundHandler: undefined, composed: undefined, closing: false }
130
+ private readonly state: RouterState = { mounts: new Set(), wrappers: new Set(), errorHandler: undefined, notFoundHandler: undefined, composed: undefined, closing: false }
129
131
  private readonly flights = new Set<Flight>()
130
132
  /** The flight of each request the router dispatched, keyed by the request its handlers see as `c.req.raw`. */
131
133
  private readonly flightOf = new WeakMap<Request, Flight>()
@@ -185,6 +187,21 @@ export class Router extends Service {
185
187
  return () => { void remove() }
186
188
  }
187
189
 
190
+ /**
191
+ * Run `middleware` around every request the router serves, whenever the plugin that asks was mounted: a middleware in a plain mount wraps only the routes mounted after it,
192
+ * which is the wrong tool for a plugin that must see every page (the door adds its script to each HTML response, the pages of a plugin that loaded earlier included). It goes
193
+ * outside all mounts, in the order wrappers were added, and goes away when the calling plugin unloads or when the returned function is called.
194
+ */
195
+ wrap(middleware: H): () => void {
196
+ this.state.wrappers.add(middleware)
197
+ this.state.composed = undefined
198
+ const remove = (): void => {
199
+ if (this.state.wrappers.delete(middleware)) this.state.composed = undefined
200
+ }
201
+ this.ctx.effect(() => remove, 'acryl-router: wrapper')
202
+ return remove
203
+ }
204
+
188
205
  /**
189
206
  * The mount's own copy of `app`'s routes as they are now (like the core's `app.route`), each handler wrapped so the router knows which
190
207
  * mount's code a request is in. The core's `app.route` runs first so a sub-app's own `onError` still applies to its routes.
@@ -331,6 +348,7 @@ export class Router extends Service {
331
348
  // over all routes on its first request, which grows faster than the route count (measured: 22 ms at 1000 routes, 163 ms at 2000),
332
349
  // while the trie rebuilds in about 1 ms per 1000 routes and matches as fast (scripts/bench-remount.mjs).
333
350
  const root = new Acryl({ strict: this.strict, router: new TrieRouter() })
351
+ for (const wrapper of this.state.wrappers) root.use('*', wrapper)
334
352
  for (const mount of this.state.mounts) root.route(mount.path, mount.app)
335
353
  root.onError(this.state.errorHandler ?? this.logError)
336
354
  if (this.state.notFoundHandler !== undefined) root.notFound(this.state.notFoundHandler)
@@ -88,6 +88,7 @@ async function start(): Promise<void> {
88
88
  const termStop = $<HTMLButtonElement>('.termstop')
89
89
  termStop.addEventListener('click', () => void terminal?.stop())
90
90
  const form = $<HTMLFormElement>('form')
91
+ document.body.append(host)
91
92
  const kindOf = (name: string): 'chat' | 'terminal' =>
92
93
  session.drivers.find((d) => d.name === name)?.kind ?? 'chat'
93
94
  let terminal: { detach(): void; stop(): Promise<void> } | undefined
@@ -147,36 +148,36 @@ async function start(): Promise<void> {
147
148
  return el
148
149
  }
149
150
 
150
- let controller: AbortController | undefined
151
- stop.addEventListener('click', () => controller?.abort())
152
- shadow.querySelector('form')!.addEventListener('submit', async (submitted) => {
153
- submitted.preventDefault()
154
- const message = input.value.trim()
155
- if (message === '' || controller) return
156
- input.value = ''
157
- line(message, 'me')
158
- controller = new AbortController()
151
+ // 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
152
+ // loads next follows it again. Only Stop cancels it.
153
+ const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
154
+ let following = false
155
+ stop.addEventListener('click', () => void fetch(`${PREFIX}/stop`, { method: 'POST', headers }))
156
+ /**
157
+ * 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
158
+ * a run that had already ended before the page loaded is only shown, never reloads the page.
159
+ */
160
+ const watch = async (response: Response, own: boolean): Promise<void> => {
161
+ let live = true
162
+ following = true
159
163
  send.disabled = true
160
164
  stop.disabled = false
161
165
  let changed = false
162
166
  let answer: HTMLElement | undefined
163
167
  try {
164
- const response = await fetch(`${PREFIX}/ask`, {
165
- method: 'POST',
166
- headers: { 'content-type': 'application/json', 'x-agent-panel-token': session.token },
167
- body: JSON.stringify({ message, driver: driver.value }),
168
- signal: controller.signal,
169
- })
170
- if (!response.ok) {
171
- line(
172
- ((await response.json().catch(() => ({}))) as { error?: string }).error ??
173
- `The panel answered ${response.status}.`,
174
- 'err',
175
- )
176
- return
177
- }
178
168
  for await (const { event, data } of events(response)) {
179
- if (event === 'text') {
169
+ if (event === 'run') {
170
+ live = data.ended !== true
171
+ if (!own) {
172
+ if (typeof data.message === 'string') line(data.message, 'me')
173
+ if (typeof data.driver === 'string' && kindOf(data.driver) === 'chat')
174
+ driver.value = data.driver
175
+ const recent =
176
+ data.ended !== true ||
177
+ (typeof data.endedAt === 'number' && Date.now() - data.endedAt < 120_000)
178
+ if (recent) show(true)
179
+ }
180
+ } else if (event === 'text') {
180
181
  answer ??= line('')
181
182
  answer.textContent += String(data.text ?? '')
182
183
  } else if (event === 'tool') {
@@ -191,24 +192,52 @@ async function start(): Promise<void> {
191
192
  }
192
193
  }
193
194
  } catch (error) {
194
- line(
195
- controller?.signal.aborted
196
- ? 'Stopped.'
197
- : `The request failed: ${error instanceof Error ? error.message : String(error)}`,
198
- 'err',
199
- )
195
+ line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
200
196
  } finally {
201
- controller = undefined
197
+ following = false
202
198
  send.disabled = false
203
199
  stop.disabled = true
204
200
  }
205
- // A plugin was installed or removed: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
206
- if (changed && input.value.trim() === '') {
201
+ // A plugin was installed or removed while this page watched: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
202
+ // A run that ended before this page loaded was already shown after its reload, and does not reload it again.
203
+ if (live && changed && input.value.trim() === '') {
207
204
  line('Reloading the page to show it.', 'tool')
208
205
  setTimeout(() => location.reload(), 900)
209
206
  }
207
+ }
208
+ form.addEventListener('submit', async (submitted) => {
209
+ submitted.preventDefault()
210
+ const message = input.value.trim()
211
+ if (message === '' || following) return
212
+ input.value = ''
213
+ line(message, 'me')
214
+ try {
215
+ const response = await fetch(`${PREFIX}/ask`, {
216
+ method: 'POST',
217
+ headers,
218
+ body: JSON.stringify({ message, driver: driver.value }),
219
+ })
220
+ if (!response.ok) {
221
+ line(
222
+ ((await response.json().catch(() => ({}))) as { error?: string }).error ??
223
+ `The panel answered ${response.status}.`,
224
+ 'err',
225
+ )
226
+ return
227
+ }
228
+ await watch(response, true)
229
+ } catch (error) {
230
+ line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
231
+ }
210
232
  })
211
- document.body.append(host)
233
+ // The latest run, if there is one: shown again after a reload (its question, its events), and followed if it is still going.
234
+ try {
235
+ const latest = await fetch(`${PREFIX}/run`, { headers })
236
+ if (latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream'))
237
+ void watch(latest, false)
238
+ } catch {
239
+ // No run to show.
240
+ }
212
241
  // A terminal that was open before a reload is still running on the server: show it again.
213
242
  try {
214
243
  const remembered = JSON.parse(sessionStorage.getItem('driver-pty-session') ?? 'null') as {