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.
- package/docs/framework/workshop/README.md +3 -3
- package/docs/framework/workshop/facilitator.md +1 -0
- package/lib/{audit-BXnYsLge.d.ts → audit-D5z9uwOP.d.ts} +14 -1
- package/lib/cordis/door.d.ts +8 -2
- package/lib/cordis/door.js +88 -12
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/router-http.d.ts +1 -1
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +12 -1
- package/lib/door/page.js +21 -0
- package/lib/door/protocol.d.ts +2 -2
- package/lib/door/protocol.js +19 -3
- package/lib/{index-D4nVR3wu.d.ts → index-DMOtA-NJ.d.ts} +7 -1
- package/lib/{protocol-BizVt4J6.d.ts → protocol-C37gPePD.d.ts} +9 -2
- package/lib/{snapshot-text-DmE0YfeZ.js → snapshot-text-BLz8nlSc.js} +49 -1
- package/lib/{src-2Z4mJg0x.js → src-dy3Fk9tA.js} +16 -0
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +5 -3
- package/plugins/acryl-door/src/host/channel.ts +36 -0
- package/plugins/acryl-door/src/host/door.ts +26 -4
- package/plugins/acryl-door/src/host/shim.ts +23 -0
- package/plugins/acryl-door/src/host/verbs.ts +4 -3
- package/plugins/acryl-door/src/index.ts +26 -3
- package/plugins/acryl-door/src/page/driver/driver.ts +22 -1
- package/plugins/acryl-door/src/page/protocol.ts +13 -2
- package/plugins/acryl-router/src/service.ts +19 -1
- package/plugins/agent-panel/client/main.ts +63 -34
- package/plugins/agent-panel/index.ts +115 -25
- 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
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|