acrylblends 0.7.8 → 0.8.1

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 (141) hide show
  1. package/dist/index.d.ts +13 -5
  2. package/dist/index.js +72 -13
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +2 -2
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/getting-started/basic.md +1 -2
  7. package/docs/framework/getting-started/desktop.md +1 -1
  8. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  9. package/docs/framework/guides/create-acrylblends.md +5 -4
  10. package/docs/framework/guides/data.md +2 -1
  11. package/docs/framework/upstream.json +3 -3
  12. package/lib/node-server.js +1 -0
  13. package/package.json +1 -1
  14. package/plugins/acryl-db/plugin.json +6 -1
  15. package/plugins/acryl-db/src/conformance.ts +224 -0
  16. package/plugins/acryl-db/upstream.json +1 -1
  17. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  18. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  19. package/plugins/acryl-door/LICENSE +47 -0
  20. package/plugins/acryl-door/plugin.json +14 -0
  21. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  22. package/plugins/acryl-door/src/host/api.ts +9 -0
  23. package/plugins/acryl-door/src/host/audit.ts +64 -0
  24. package/plugins/acryl-door/src/host/channel.ts +142 -0
  25. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  26. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  27. package/plugins/acryl-door/src/host/door.ts +195 -0
  28. package/plugins/acryl-door/src/host/http.ts +80 -0
  29. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  30. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  31. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  32. package/plugins/acryl-door/src/host/shim.ts +65 -0
  33. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  34. package/plugins/acryl-door/src/index.ts +133 -0
  35. package/plugins/acryl-door/src/page/api.ts +9 -0
  36. package/plugins/acryl-door/src/page/channel.ts +135 -0
  37. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  38. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  39. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  40. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  41. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  42. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  43. package/plugins/acryl-door/src/page/index.ts +40 -0
  44. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  45. package/plugins/acryl-door/src/page/main.ts +7 -0
  46. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  47. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  48. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  49. package/plugins/acryl-door/upstream.json +28 -0
  50. package/plugins/acryl-inspect/upstream.json +1 -1
  51. package/plugins/acryl-kv/plugin.json +6 -1
  52. package/plugins/acryl-kv/src/conformance.ts +226 -0
  53. package/plugins/acryl-kv/upstream.json +1 -1
  54. package/plugins/acryl-kv-db/plugin.json +4 -1
  55. package/plugins/acryl-kv-db/upstream.json +1 -1
  56. package/plugins/acryl-kv-json/plugin.json +4 -1
  57. package/plugins/acryl-kv-json/upstream.json +1 -1
  58. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  59. package/plugins/acryl-node-server/upstream.json +2 -2
  60. package/plugins/acryl-router/src/service.ts +3 -0
  61. package/plugins/acryl-router/upstream.json +2 -2
  62. package/plugins/acryl-router-http/upstream.json +1 -1
  63. package/plugins/acryl-state/upstream.json +1 -1
  64. package/plugins/agent-panel/client/main.ts +159 -0
  65. package/plugins/agent-panel/index.ts +232 -0
  66. package/plugins/agent-panel/plugin.json +21 -0
  67. package/plugins/desktop/upstream.json +1 -1
  68. package/plugins/desktop-web/upstream.json +1 -1
  69. package/plugins/extensions/resolve.ts +16 -2
  70. package/plugins/tsconfig.json +6 -0
  71. package/shared/DEFAULTS.md +11 -6
  72. package/shared/SKILL.md +1 -1
  73. package/templates/agent/blend.yaml +3 -0
  74. package/templates/agent/src/app.ts +18 -2
  75. package/templates/agent/src/routes.ts +14 -1
  76. package/templates/agent/template.json +2 -1
  77. package/templates/agent/test/agent.test.ts +10 -4
  78. package/templates/agent/test/app.test.ts +29 -8
  79. package/templates/agent/test/builder.test.ts +34 -23
  80. package/templates/agent/test/model.test.ts +24 -19
  81. package/templates/blank/src/app.ts +25 -5
  82. package/templates/blank/test/app.test.ts +28 -9
  83. package/templates/chat/AGENTS.md +16 -0
  84. package/templates/chat/blend.yaml +32 -0
  85. package/templates/chat/gitignore +6 -0
  86. package/templates/chat/migrations/001-chat.sql +12 -0
  87. package/templates/chat/package.json +33 -0
  88. package/templates/chat/src/app.ts +107 -0
  89. package/templates/chat/src/chat-app.ts +101 -0
  90. package/templates/chat/src/client/chat.tsx +115 -0
  91. package/templates/chat/src/client/tsconfig.json +26 -0
  92. package/templates/chat/src/db.ts +43 -0
  93. package/templates/chat/src/door.ts +7 -0
  94. package/templates/chat/src/llm.ts +42 -0
  95. package/templates/chat/src/main.ts +28 -0
  96. package/templates/chat/src/page.tsx +40 -0
  97. package/templates/chat/src/routes.ts +15 -0
  98. package/templates/chat/src/runtime.ts +17 -0
  99. package/templates/chat/template.json +12 -0
  100. package/templates/chat/test/chat.test.ts +198 -0
  101. package/templates/chat/test/server.test.ts +78 -0
  102. package/templates/chat/tsconfig.json +20 -0
  103. package/templates/nodejs/AGENTS.md +9 -0
  104. package/templates/nodejs/blend.yaml +29 -0
  105. package/templates/nodejs/gitignore +3 -0
  106. package/templates/nodejs/package.json +30 -0
  107. package/templates/nodejs/src/app.ts +95 -0
  108. package/templates/nodejs/src/client/counter.tsx +12 -0
  109. package/templates/nodejs/src/client/tsconfig.json +26 -0
  110. package/templates/nodejs/src/door.ts +7 -0
  111. package/templates/nodejs/src/main.ts +25 -0
  112. package/templates/nodejs/src/page.tsx +29 -0
  113. package/templates/nodejs/src/routes.ts +40 -0
  114. package/templates/nodejs/template.json +11 -0
  115. package/templates/nodejs/test/app.test.ts +78 -0
  116. package/templates/nodejs/test/server.test.ts +83 -0
  117. package/templates/nodejs/tsconfig.json +20 -0
  118. package/templates/notes/AGENTS.md +26 -0
  119. package/templates/notes/blend.yaml +32 -0
  120. package/templates/notes/gitignore +6 -0
  121. package/templates/notes/migrations/001-notes.sql +18 -0
  122. package/templates/notes/package.json +33 -0
  123. package/templates/notes/src/app.ts +107 -0
  124. package/templates/notes/src/auth.ts +57 -0
  125. package/templates/notes/src/client/counter.tsx +21 -0
  126. package/templates/notes/src/client/tsconfig.json +26 -0
  127. package/templates/notes/src/db.ts +50 -0
  128. package/templates/notes/src/door.ts +7 -0
  129. package/templates/notes/src/main.ts +28 -0
  130. package/templates/notes/src/notes-app.ts +205 -0
  131. package/templates/notes/src/routes.ts +14 -0
  132. package/templates/notes/src/runtime.ts +17 -0
  133. package/templates/notes/src/schema.ts +38 -0
  134. package/templates/notes/src/views.tsx +136 -0
  135. package/templates/notes/template.json +12 -0
  136. package/templates/notes/test/notes.test.ts +280 -0
  137. package/templates/notes/test/server.test.ts +78 -0
  138. package/templates/notes/tsconfig.json +20 -0
  139. package/templates/todo/src/app.ts +27 -7
  140. package/templates/todo/test/todo.test.ts +47 -22
  141. package/templates/todo/upstream.json +1 -1
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `acryl-door`: the Door of the five nouns, as a Cordis plugin of a framework app (spec 049 slice 9). An agent working in the app folder
3
+ * looks at the running page, acts on it, and installs plugins live, through `.acryl/bin/acryl`; everything else it does with its own
4
+ * file tools and shell.
5
+ *
6
+ * The page loads `/api/acryl-door/page.js` (one script tag) and keeps an event stream open to the Host; the Host sends it calls. An
7
+ * operator on this machine reaches `/api/acryl-door/call` with the secret from `<app>/.acryl/door.json`. One effect owns the channel and
8
+ * the audit, the routes are mounted after it, and a second effect writes the connection file last, so disposal removes the file first,
9
+ * then the routes (their in-flight calls end), then closes the channel (every pending call settles).
10
+ */
11
+
12
+ import { join } from 'node:path'
13
+ import type { Context } from '@deepseek-ai/cordis'
14
+ import z from '@deepseek-ai/schemastery'
15
+ import { Acryl } from '../../acryl-router/src/index.ts'
16
+ import { clientModules } from '../../acryl-node-server/src/client-modules.ts'
17
+ import { auditPath, AuditLog } from './host/audit.ts'
18
+ import { UiChannel } from './host/channel.ts'
19
+ import { registerChatTools } from './host/chat-tools.ts'
20
+ import { Door, type ExtensionsPort, type ToolsPort } from './host/door.ts'
21
+ import { newSecret, removeDoorFile, stateDir, writeDoorFile } from './host/door-file.ts'
22
+ import { DOOR_PREFIX, privateApi } from './host/http.ts'
23
+ import { mcpRoutes } from './host/mcp-routes.ts'
24
+ import { operatorRoutes } from './host/operator-routes.ts'
25
+ import { pageChannelRoutes } from './host/page-routes.ts'
26
+ import { writeShims, type ShimTarget } from './host/shim.ts'
27
+
28
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
29
+ export const name = 'acryl-door'
30
+
31
+ /** The part of acrylblends' `appInstance` the door reads: the app folder and its name. */
32
+ interface AppFolder {
33
+ readonly root: string
34
+ readonly name: string
35
+ }
36
+
37
+ /** The part of `acryl-node-server` the door reads: where the app listens. */
38
+ interface Listening {
39
+ readonly url: string
40
+ }
41
+
42
+ declare module '@deepseek-ai/cordis' {
43
+ interface Context {
44
+ door: Door
45
+ }
46
+ }
47
+
48
+ export const inject = ['router', 'appInstance', 'nodeServer']
49
+
50
+ export interface Config {
51
+ tools?: { expose?: string[] }
52
+ }
53
+
54
+ export const Config: z<Config> = z.object({
55
+ tools: z.object({
56
+ // Not a `door_` name: those are the door's own verbs in the registry, never offered back as app tools.
57
+ 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.'),
58
+ }),
59
+ })
60
+
61
+ /** The page module, served compiled from source by the app's own TypeScript. */
62
+ const PAGE_DIR = join(import.meta.dirname, import.meta.url.endsWith('.ts') ? 'page' : join('..', 'src', 'page'))
63
+
64
+ /** The operator client this build runs: the source next to this file, or the built `cli.js`. */
65
+ const CLIENT = import.meta.url.endsWith('.ts') ? join(import.meta.dirname, 'cli', 'acryl.ts') : join(import.meta.dirname, 'cli.js')
66
+
67
+ /** 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. */
68
+ function shimTarget(): ShimTarget {
69
+ const fromSource = CLIENT.endsWith('.ts')
70
+ return {
71
+ node: process.execPath,
72
+ nodeOptions: fromSource ? process.execArgv.filter(option => /^--experimental-(strip|transform)-types$/u.test(option)) : [],
73
+ client: CLIENT,
74
+ electron: process.versions.electron !== undefined,
75
+ }
76
+ }
77
+
78
+ /** A listening address as one the operator reaches: an any-address bind is reached on loopback. */
79
+ function loopbackUrl(url: string): string {
80
+ const parsed = new URL(url)
81
+ if (parsed.hostname === '0.0.0.0' || parsed.hostname === '[::]') parsed.hostname = '127.0.0.1'
82
+ return parsed.origin
83
+ }
84
+
85
+ export function apply(ctx: Context, rawConfig: Config = {}): void {
86
+ const config = Config(rawConfig)
87
+ const app = ctx.get('appInstance') as unknown as AppFolder
88
+ const server = ctx.get('nodeServer') as unknown as Listening
89
+ if (typeof app?.root !== 'string') throw new Error('acryl-door: the appInstance service has no app folder (root)')
90
+ const reportError = (operation: string, cause: unknown): void => {
91
+ ctx.logger?.error?.(`acryl-door: failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`)
92
+ }
93
+ const secret = newSecret()
94
+ const channel = new UiChannel()
95
+ const audit = new AuditLog(auditPath(stateDir(app.root)))
96
+ const door = new Door({
97
+ channel,
98
+ audit,
99
+ app: { name: app.name, root: app.root },
100
+ extensions: () => ctx.get('extensions') as unknown as ExtensionsPort | undefined,
101
+ tools: () => ctx.get('tools') as unknown as ToolsPort | undefined,
102
+ expose: config.tools?.expose ?? [],
103
+ })
104
+ ctx.effect(() => () => { channel.close() }, 'acryl-door: page channel')
105
+ // One script tag for the page (`page.js`): it imports the module tree served below it.
106
+ const pageModule = new Acryl()
107
+ .get(`${DOOR_PREFIX}/page.js`, c => c.body(`import '${DOOR_PREFIX}/page/main.ts'\n`, 200, { 'content-type': 'text/javascript; charset=utf-8' }))
108
+ .route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR }))
109
+ ctx.router.route('/', privateApi(
110
+ DOOR_PREFIX,
111
+ pageModule,
112
+ pageChannelRoutes(channel, { prefix: DOOR_PREFIX }),
113
+ operatorRoutes(DOOR_PREFIX, secret, door, reportError),
114
+ mcpRoutes(DOOR_PREFIX, secret, door, reportError),
115
+ ))
116
+ // The built-in chat's agent reads the `tools` registry: the verbs are in it while both are up.
117
+ ctx.inject(['tools'], (inner) => {
118
+ inner.effect(() => registerChatTools(inner.get('tools') as unknown as ToolsPort, door), 'acryl-door: the verbs in the tools registry')
119
+ })
120
+ ctx.effect(function* () {
121
+ yield ctx.reflect.provide('door', door)
122
+ writeDoorFile(app.root, loopbackUrl(server.url), secret)
123
+ writeShims(app.root, shimTarget())
124
+ yield () => { removeDoorFile(app.root, secret) }
125
+ }, 'acryl-door: the door service, .acryl/door.json and .acryl/bin/acryl')
126
+ }
127
+
128
+ export { Door, DoorError, type VerbAnswer } from './host/door.ts'
129
+ export { chatToolName, VERB_SPECS, VERBS, type Verb, type VerbSpec } from './host/verbs.ts'
130
+ export { mcpTools } from './host/mcp-routes.ts'
131
+ export { DOOR_PREFIX } from './host/http.ts'
132
+ export { doorFilePath, readDoorFile, STATE_DIR, type DoorFile } from './host/door-file.ts'
133
+ export type { CallResponse } from './host/operator-routes.ts'
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The door's page core for another page to build its own door on (`acryl-door/page`): the channel to the Host, the driver of the
3
+ * document, the user-input clock and the plain DOM indicator. ACRYL's DSH client uses the first three with its own React indicator.
4
+ */
5
+
6
+ export { PageChannel, SILENCE_MS, type ChannelSource, type PageChannelOptions } from './channel.ts'
7
+ export { UiDriver, type DriverActivity, type UiDriverOptions } from './driver/driver.ts'
8
+ export { UserInputClock } from './user-input.ts'
9
+ export { mountIndicator } from './indicator.ts'
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The page's end of the door's channel.
3
+ *
4
+ * It keeps an event stream open to the Host (`GET <base>/channel`), runs each call on the driver, and posts the answer back. The Host
5
+ * writes a `ping` every few seconds; a stream silent for {@link SILENCE_MS} is dropped and opened again, so a page whose connection died
6
+ * without an error (a sleeping laptop, a proxy) resumes on its own. A page that reconnects keeps its window id, and the Host settles a
7
+ * call in flight on the old stream as `disconnected`.
8
+ */
9
+
10
+ import { UiControlError, parseChannelToPage, type ChannelToHost } from './protocol.ts'
11
+ import type { UiDriver } from './driver/driver.ts'
12
+
13
+ /** The slice of an `EventSource` the channel uses, so tests can supply their own. */
14
+ export interface ChannelSource {
15
+ addEventListener(type: string, listener: (event: MessageEvent) => void): void
16
+ onerror: ((event: Event) => void) | null
17
+ close(): void
18
+ readonly readyState: number
19
+ }
20
+
21
+ /** The Host's heartbeat is 15 s; three missed beats and the stream counts as dead. */
22
+ export const SILENCE_MS = 45_000
23
+ const CLOSED = 2
24
+ const BACKOFF_START_MS = 500
25
+ const BACKOFF_MAX_MS = 10_000
26
+
27
+ export interface PageChannelOptions {
28
+ readonly windowId: string
29
+ readonly focused: () => boolean
30
+ /** Where the door's routes are, e.g. `/api/acryl-door`. */
31
+ readonly base: string
32
+ readonly createSource?: (url: string) => ChannelSource
33
+ readonly post?: (url: string, body: string) => Promise<unknown>
34
+ readonly silenceMs?: number
35
+ readonly now?: () => number
36
+ }
37
+
38
+ export class PageChannel {
39
+ private source: ChannelSource | undefined
40
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined
41
+ private watchdog: ReturnType<typeof setInterval> | undefined
42
+ private backoff = BACKOFF_START_MS
43
+ private lastHeard = 0
44
+ private disposed = false
45
+ private readonly now: () => number
46
+
47
+ private readonly driver: UiDriver
48
+ private readonly options: PageChannelOptions
49
+
50
+ constructor(driver: UiDriver, options: PageChannelOptions) {
51
+ this.driver = driver
52
+ this.options = options
53
+ this.now = options.now ?? (() => Date.now())
54
+ }
55
+
56
+ connect(): void {
57
+ if (this.disposed || this.source !== undefined) return
58
+ const create = this.options.createSource ?? ((url: string): ChannelSource => new EventSource(url))
59
+ const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}`
60
+ const source = create(`${this.options.base}/channel?${query}`)
61
+ this.source = source
62
+ this.lastHeard = this.now()
63
+ const heard = (): void => {
64
+ this.lastHeard = this.now()
65
+ this.backoff = BACKOFF_START_MS
66
+ }
67
+ source.addEventListener('ready', heard)
68
+ source.addEventListener('ping', heard)
69
+ source.addEventListener('call', (event) => {
70
+ heard()
71
+ if (typeof event.data === 'string') this.answer(event.data)
72
+ })
73
+ // An EventSource retries by itself after a network error; once it gives up (CLOSED), the channel starts over.
74
+ source.onerror = () => { if (source.readyState === CLOSED) this.restart(source) }
75
+ const silence = this.options.silenceMs ?? SILENCE_MS
76
+ this.watchdog = setInterval(() => {
77
+ if (this.now() - this.lastHeard > silence) this.restart(source)
78
+ }, Math.max(50, Math.floor(silence / 3)))
79
+ }
80
+
81
+ /** Tell the Host whether this window is the one in front. */
82
+ reportFocus(focused: boolean): void {
83
+ this.send({ t: 'focus', focused })
84
+ }
85
+
86
+ dispose(): void {
87
+ this.disposed = true
88
+ if (this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer)
89
+ this.stop()
90
+ }
91
+
92
+ /** Drop the stream and open a new one after the backoff. */
93
+ private restart(source: ChannelSource): void {
94
+ if (this.source !== source) return
95
+ this.stop()
96
+ if (this.disposed) return
97
+ this.reconnectTimer = setTimeout(() => { this.reconnectTimer = undefined; this.connect() }, this.backoff)
98
+ this.backoff = Math.min(this.backoff * 2, BACKOFF_MAX_MS)
99
+ }
100
+
101
+ private stop(): void {
102
+ if (this.watchdog !== undefined) clearInterval(this.watchdog)
103
+ this.watchdog = undefined
104
+ const source = this.source
105
+ this.source = undefined
106
+ if (source !== undefined) {
107
+ source.onerror = null
108
+ source.close()
109
+ }
110
+ }
111
+
112
+ private answer(text: string): void {
113
+ let call
114
+ try {
115
+ call = parseChannelToPage(text)
116
+ } catch {
117
+ // A malformed call carries no usable id; there is nothing to answer.
118
+ return
119
+ }
120
+ this.driver.handle(call.request).then(
121
+ (value) => { this.send({ t: 'result', id: call.id, value }) },
122
+ (cause: unknown) => {
123
+ const failure = cause instanceof UiControlError ? cause : new UiControlError('not-actionable', cause instanceof Error ? cause.message : 'the action failed')
124
+ this.send({ t: 'error', id: call.id, code: failure.code, message: failure.message })
125
+ },
126
+ )
127
+ }
128
+
129
+ private send(message: ChannelToHost): void {
130
+ const post = this.options.post ?? ((url: string, body: string) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
131
+ post(`${this.options.base}/channel/answer`, JSON.stringify({ window: this.options.windowId, message })).catch(() => {
132
+ // The Host is gone or restarting; the call it made settles on its side.
133
+ })
134
+ }
135
+ }
@@ -0,0 +1,163 @@
1
+ /** Roles, accessible names and states of DOM elements, in the small subset a snapshot needs. */
2
+
3
+ import { isSensitive } from './sensitivity.ts'
4
+
5
+ const INPUT_ROLES: Record<string, string> = {
6
+ checkbox: 'checkbox', radio: 'radio', button: 'button', submit: 'button', reset: 'button', image: 'button',
7
+ range: 'slider', number: 'spinbutton', search: 'searchbox', text: 'textbox', email: 'textbox', url: 'textbox', tel: 'textbox', password: 'textbox',
8
+ }
9
+
10
+ const LANDMARKS: Record<string, string> = { nav: 'navigation', main: 'main', aside: 'complementary', dialog: 'dialog', form: 'form' }
11
+
12
+ /** ARIA roles an element may declare that the driver treats as interactive or structural. */
13
+ const EXPLICIT_ROLES = new Set([
14
+ 'button', 'link', 'tab', 'tabpanel', 'menu', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'checkbox', 'radio', 'switch', 'textbox',
15
+ 'searchbox', 'combobox', 'listbox', 'option', 'slider', 'spinbutton', 'dialog', 'alertdialog', 'alert', 'status', 'navigation', 'main',
16
+ 'heading', 'tree', 'treeitem', 'row', 'gridcell', 'tablist', 'toolbar', 'region', 'form', 'img',
17
+ ])
18
+
19
+ /** @returns the element's role, or null when it is not something a snapshot lists. */
20
+ export function roleOf(el: Element): string | null {
21
+ const explicit = el.getAttribute('role')?.trim().toLowerCase().split(/\s+/)[0]
22
+ if (explicit !== undefined && explicit !== '' && EXPLICIT_ROLES.has(explicit)) return explicit
23
+ const tag = el.tagName.toLowerCase()
24
+ if (tag === 'a') return el.hasAttribute('href') ? 'link' : null
25
+ if (tag === 'button' || tag === 'summary') return 'button'
26
+ if (tag === 'select') return (el as HTMLSelectElement).multiple ? 'listbox' : 'combobox'
27
+ if (tag === 'textarea') return 'textbox'
28
+ if (/^h[1-6]$/.test(tag)) return 'heading'
29
+ if (tag === 'img') return el.getAttribute('alt')?.trim() ? 'img' : null
30
+ if (tag === 'input') {
31
+ const type = (el.getAttribute('type') ?? 'text').toLowerCase()
32
+ if (type === 'hidden') return null
33
+ return INPUT_ROLES[type] ?? 'textbox'
34
+ }
35
+ if (el.hasAttribute('contenteditable') && el.getAttribute('contenteditable') !== 'false') return 'textbox'
36
+ // A form is only a landmark when it has a name; an unnamed one is noise in a snapshot.
37
+ if (tag === 'form') return el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby') ? 'form' : null
38
+ return LANDMARKS[tag] ?? null
39
+ }
40
+
41
+ /** Roles whose accessible name is the text they contain, so that text is not listed again on its own. */
42
+ export const NAMED_BY_CONTENT: ReadonlySet<string> = new Set(['button', 'link', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'heading', 'switch', 'treeitem', 'checkbox', 'radio', 'alert', 'status'])
43
+
44
+ /** Roles that show their content as a value (a field, a native select), so the text inside them is not listed on its own either. */
45
+ export const SHOWN_AS_VALUE: ReadonlySet<string> = new Set(['textbox', 'searchbox', 'combobox', 'spinbutton'])
46
+
47
+ export const collapse = (value: string): string => value.replace(/\s+/g, ' ').trim()
48
+ export const clip = (value: string, max: number): string => (value.length > max ? `${value.slice(0, max - 1)}…` : value)
49
+
50
+ /** The text a reader would hear: like `textContent`, but leaving out what is `aria-hidden` or `hidden` (a check mark, an icon glyph). */
51
+ function readableText(root: Node): string {
52
+ const parts: string[] = []
53
+ const walk = (node: Node): void => {
54
+ if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
55
+ else if (!(node instanceof Element && (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden')))) node.childNodes.forEach(walk)
56
+ }
57
+ // The element being named is never skipped itself; only what it contains.
58
+ root.childNodes.forEach(walk)
59
+ return parts.join('')
60
+ }
61
+
62
+ const textOf = (el: Element, max: number): string => clip(collapse(readableText(el)), max)
63
+
64
+ /** The role of a run of visible text that is not already a control's name or a field's value: what an agent reads a value from. */
65
+ export const TEXT_ROLE = 'text'
66
+
67
+ /** The longest text run a snapshot carries. */
68
+ export const MAX_TEXT = 200
69
+
70
+ /** One run of text as a snapshot lists it: collapsed and clipped, empty when there is nothing to read. */
71
+ export const textRun = (raw: string): string => clip(collapse(raw), MAX_TEXT)
72
+
73
+ /** The runs of text directly inside an element (between its child elements), as a snapshot lists them. */
74
+ export function textRunsOf(el: Element): string[] {
75
+ const runs: string[] = []
76
+ let run = ''
77
+ for (const child of Array.from(el.childNodes)) {
78
+ if (child.nodeType === Node.TEXT_NODE) run += child.textContent ?? ''
79
+ else if (child instanceof Element) { runs.push(textRun(run)); run = '' }
80
+ }
81
+ runs.push(textRun(run))
82
+ return runs.filter(text => text !== '')
83
+ }
84
+
85
+ /** ARIA-style accessible name, kept short. */
86
+ export function nameOf(el: Element, role: string): string {
87
+ const aria = el.getAttribute('aria-label')?.trim()
88
+ if (aria !== undefined && aria !== '') return clip(collapse(aria), 120)
89
+ const by = el.getAttribute('aria-labelledby')
90
+ if (by !== null && by.trim() !== '') {
91
+ const joined = collapse(by.split(/\s+/).map(id => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' '))
92
+ if (joined !== '') return clip(joined, 120)
93
+ }
94
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
95
+ const id = el.id
96
+ const explicit = id === '' ? null : el.ownerDocument.querySelector(`label[for="${CSS.escape(id)}"]`)
97
+ const wrapping = el.closest('label')
98
+ const label = explicit ?? wrapping
99
+ const labelText = label === null ? '' : collapse(readableText(label))
100
+ if (labelText !== '') return clip(labelText, 120)
101
+ if (el instanceof HTMLInputElement && (el.type === 'button' || el.type === 'submit' || el.type === 'reset') && el.value !== '') return clip(el.value, 120)
102
+ const placeholder = el.getAttribute('placeholder')?.trim()
103
+ if (placeholder !== undefined && placeholder !== '') return clip(placeholder, 120)
104
+ }
105
+ if (role === 'img') return clip(collapse(el.getAttribute('alt') ?? ''), 120)
106
+ if (NAMED_BY_CONTENT.has(role)) {
107
+ const text = textOf(el, 120)
108
+ if (text !== '') return text
109
+ }
110
+ if (role === 'alert' || role === 'status' || role === 'dialog' || role === 'alertdialog') {
111
+ const text = textOf(el, 160)
112
+ if (text !== '') return text
113
+ }
114
+ return clip(collapse(el.getAttribute('title') ?? ''), 120)
115
+ }
116
+
117
+ /** The state words a snapshot shows for an element. */
118
+ export function statesOf(el: Element, role: string): string[] {
119
+ const states: string[] = []
120
+ const aria = (name: string): string | null => el.getAttribute(name)
121
+ if ((el as HTMLButtonElement).disabled === true || aria('aria-disabled') === 'true') states.push('disabled')
122
+ if (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio')) { if (el.checked) states.push('checked') } else if (aria('aria-checked') === 'true') states.push('checked')
123
+ if (aria('aria-expanded') === 'true') states.push('expanded')
124
+ if (aria('aria-expanded') === 'false') states.push('collapsed')
125
+ if (aria('aria-selected') === 'true') states.push('selected')
126
+ if (aria('aria-pressed') === 'true') states.push('pressed')
127
+ if (aria('aria-current') !== null && aria('aria-current') !== 'false') states.push('current')
128
+ if (aria('aria-invalid') === 'true') states.push('invalid')
129
+ if ((el as HTMLInputElement).required === true || aria('aria-required') === 'true') states.push('required')
130
+ if ((el as HTMLInputElement).readOnly === true || aria('aria-readonly') === 'true') states.push('readonly')
131
+ if (el.ownerDocument.activeElement === el) states.push('focused')
132
+ void role
133
+ return states
134
+ }
135
+
136
+ /** The visible value of an editable field, or undefined when it is empty, not editable, or sensitive. */
137
+ export function valueOf(el: Element, role: string): string | undefined {
138
+ if (role !== 'textbox' && role !== 'searchbox' && role !== 'combobox' && role !== 'spinbutton') return undefined
139
+ if (isSensitive(el)) return undefined
140
+ let value: string | undefined
141
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) value = el.value
142
+ else if (el instanceof HTMLSelectElement) value = el.selectedOptions[0]?.textContent ?? undefined
143
+ else if (el.hasAttribute('contenteditable')) value = el.textContent ?? undefined
144
+ return value === undefined || value === '' ? undefined : clip(value, 100)
145
+ }
146
+
147
+ /** Heading level, when the element is a heading. */
148
+ export function levelOf(el: Element): number | undefined {
149
+ const explicit = Number(el.getAttribute('aria-level'))
150
+ if (Number.isInteger(explicit) && explicit >= 1 && explicit <= 6) return explicit
151
+ const match = /^h([1-6])$/.exec(el.tagName.toLowerCase())
152
+ return match === null ? undefined : Number(match[1])
153
+ }
154
+
155
+ /** Hidden from everyone: the driver skips it and everything inside it. */
156
+ export function isHidden(el: Element): boolean {
157
+ if (el.hasAttribute('hidden') || el.getAttribute('aria-hidden') === 'true') return true
158
+ if (el instanceof HTMLInputElement && el.type === 'hidden') return true
159
+ const view = el.ownerDocument.defaultView
160
+ if (view === null) return false
161
+ const style = view.getComputedStyle(el)
162
+ return style.display === 'none' || style.visibility === 'hidden'
163
+ }
@@ -0,0 +1,186 @@
1
+ /** The actions the driver performs on the page, each guarded by the same rules. */
2
+
3
+ import { MAX_WAIT_MS, UiControlError, type UiActionResult, type UiRequest } from '../protocol.ts'
4
+ import { isHidden, nameOf, roleOf } from './accessibility.ts'
5
+ import type { RefTable } from './ref-table.ts'
6
+ import { isProtected, isSensitive } from './sensitivity.ts'
7
+ import { DRIVER_UI_ATTRIBUTE } from './snapshot.ts'
8
+
9
+ type Request<Op extends UiRequest['op']> = Extract<UiRequest, { op: Op }>
10
+
11
+ const EDITABLE_ROLES = new Set(['textbox', 'searchbox', 'spinbutton', 'combobox'])
12
+ const POLL_MS = 50
13
+
14
+ export interface ActionEnvironment {
15
+ readonly refs: RefTable
16
+ readonly document: () => Document
17
+ /** Resolves after `ms`; rejects when `signal` aborts. Injectable so tests need no real time. */
18
+ readonly sleep: (ms: number, signal: AbortSignal) => Promise<void>
19
+ }
20
+
21
+ const target = (role: string, name: string): { role: string; name: string } => ({ role, name })
22
+
23
+ function effectivelyHidden(element: Element): boolean {
24
+ for (let node: Element | null = element; node !== null; node = node.parentElement) if (isHidden(node)) return true
25
+ return false
26
+ }
27
+
28
+ /** Everything an action checks before touching an element. */
29
+ function prepare(env: ActionEnvironment, ref: string): { element: Element; role: string; name: string } {
30
+ const resolved = env.refs.resolve(ref)
31
+ const { element } = resolved
32
+ if (isSensitive(element)) throw new UiControlError('sensitive', 'that is a password, token or payment field; the agent does not read or type into it')
33
+ if (isProtected(element)) throw new UiControlError('protected', `"${resolved.name}" belongs to approval, permissions or Agent Control itself; only the user operates it`)
34
+ if (effectivelyHidden(element)) throw new UiControlError('not-actionable', `"${resolved.name}" is not visible`)
35
+ if ((element as HTMLButtonElement).disabled === true || element.getAttribute('aria-disabled') === 'true') {
36
+ throw new UiControlError('not-actionable', `"${resolved.name}" is disabled`)
37
+ }
38
+ return resolved
39
+ }
40
+
41
+ function mouse(element: Element, type: string): void {
42
+ element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }))
43
+ }
44
+
45
+ /** xterm.js reads neither the value of its input textarea nor a bare synthetic key: text arrives as a paste, and key handling reads the legacy `keyCode`. */
46
+ function isTerminalInput(element: Element): boolean {
47
+ return element.classList.contains('xterm-helper-textarea')
48
+ }
49
+
50
+ function pasteInto(element: Element, text: string): void {
51
+ const clipboardData = new DataTransfer()
52
+ clipboardData.setData('text/plain', text)
53
+ element.dispatchEvent(new ClipboardEvent('paste', { clipboardData, bubbles: true, cancelable: true }))
54
+ }
55
+
56
+ const LEGACY_KEY_CODES: Readonly<Record<string, number>> = {
57
+ Backspace: 8, Tab: 9, Enter: 13, Escape: 27, ' ': 32, PageUp: 33, PageDown: 34, End: 35, Home: 36,
58
+ ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, Delete: 46,
59
+ }
60
+
61
+ function setNativeValue(element: HTMLInputElement | HTMLTextAreaElement, value: string): void {
62
+ const proto = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
63
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
64
+ if (setter === undefined) element.value = value
65
+ else setter.call(element, value)
66
+ }
67
+
68
+ export function click(env: ActionEnvironment, request: Request<'click'>): UiActionResult {
69
+ const { element, role, name } = prepare(env, request.ref)
70
+ ;(element as HTMLElement).scrollIntoView?.({ block: 'nearest' })
71
+ ;(element as HTMLElement).focus?.()
72
+ mouse(element, 'mousedown')
73
+ mouse(element, 'mouseup')
74
+ ;(element as HTMLElement).click()
75
+ return { ok: true, target: target(role, name) }
76
+ }
77
+
78
+ export function typeText(env: ActionEnvironment, request: Request<'type'>): UiActionResult {
79
+ const { element, role, name } = prepare(env, request.ref)
80
+ if (!EDITABLE_ROLES.has(role) || (role === 'combobox' && element instanceof HTMLSelectElement)) {
81
+ throw new UiControlError('not-actionable', `"${name}" is a ${role}, not a text field; use select or click`)
82
+ }
83
+ if ((element as HTMLInputElement).readOnly === true || element.getAttribute('aria-readonly') === 'true') {
84
+ throw new UiControlError('not-actionable', `"${name}" is read-only`)
85
+ }
86
+ const clear = request.clear ?? true
87
+ ;(element as HTMLElement).focus?.()
88
+ if (isTerminalInput(element)) {
89
+ // A terminal has no text to clear: what is typed goes to the process that is running in it.
90
+ pasteInto(element, request.text)
91
+ } else if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
92
+ setNativeValue(element, clear ? request.text : element.value + request.text)
93
+ element.dispatchEvent(new Event('input', { bubbles: true }))
94
+ element.dispatchEvent(new Event('change', { bubbles: true }))
95
+ } else {
96
+ element.textContent = clear ? request.text : (element.textContent ?? '') + request.text
97
+ element.dispatchEvent(new Event('input', { bubbles: true }))
98
+ }
99
+ if (request.submit === true) pressKey(env, { op: 'press', key: 'Enter', ref: request.ref })
100
+ return { ok: true, target: target(role, name), detail: `typed ${String(request.text.length)} characters` }
101
+ }
102
+
103
+ export function selectOption(env: ActionEnvironment, request: Request<'select'>): UiActionResult {
104
+ const { element, role, name } = prepare(env, request.ref)
105
+ if (!(element instanceof HTMLSelectElement)) throw new UiControlError('not-actionable', `"${name}" is a ${role}, not a select; click it and pick the option`)
106
+ const wanted = request.option.trim().toLowerCase()
107
+ const options = Array.from(element.options)
108
+ const match = options.find(option => option.value.toLowerCase() === wanted) ?? options.find(option => (option.textContent ?? '').trim().toLowerCase() === wanted)
109
+ if (match === undefined) throw new UiControlError('not-found', `"${name}" has no option "${request.option}"`)
110
+ if (match.disabled) throw new UiControlError('not-actionable', `the option "${request.option}" is disabled`)
111
+ element.value = match.value
112
+ element.dispatchEvent(new Event('input', { bubbles: true }))
113
+ element.dispatchEvent(new Event('change', { bubbles: true }))
114
+ return { ok: true, target: target(role, name), detail: `selected ${match.textContent?.trim() ?? match.value}` }
115
+ }
116
+
117
+ export function pressKey(env: ActionEnvironment, request: Request<'press'>): UiActionResult {
118
+ const doc = env.document()
119
+ let element: Element
120
+ let role: string
121
+ let name: string
122
+ if (request.ref !== undefined) {
123
+ ({ element, role, name } = prepare(env, request.ref))
124
+ } else {
125
+ element = doc.activeElement ?? doc.body
126
+ if (isSensitive(element)) throw new UiControlError('sensitive', 'the focused field is a password, token or payment field')
127
+ if (isProtected(element)) throw new UiControlError('protected', 'the focused control belongs to approval, permissions or Agent Control itself')
128
+ role = roleOf(element) ?? 'page'
129
+ name = roleOf(element) === null ? '' : nameOf(element, role)
130
+ }
131
+ const key = request.key === 'Space' ? ' ' : request.key
132
+ const keyCode = LEGACY_KEY_CODES[key]
133
+ const init = { key, bubbles: true, cancelable: true, ...(keyCode === undefined ? {} : { keyCode, which: keyCode }) }
134
+ ;(element as HTMLElement).focus?.()
135
+ const proceed = element.dispatchEvent(new KeyboardEvent('keydown', init))
136
+ if (proceed) {
137
+ // A synthetic key event does not do what a real one does, so the two defaults that matter are done here.
138
+ if ((key === 'Enter' || key === ' ') && (role === 'button' || role === 'link')) (element as HTMLElement).click()
139
+ else if (key === 'Enter' && element instanceof HTMLInputElement && element.form !== null) element.form.requestSubmit?.()
140
+ }
141
+ element.dispatchEvent(new KeyboardEvent('keyup', init))
142
+ return { ok: true, target: target(role, name), detail: `pressed ${request.key}` }
143
+ }
144
+
145
+ export function scroll(env: ActionEnvironment, request: Request<'scroll'>): UiActionResult {
146
+ const doc = env.document()
147
+ const amount = request.amount ?? 400
148
+ const dx = request.direction === 'left' ? -amount : request.direction === 'right' ? amount : 0
149
+ const dy = request.direction === 'up' ? -amount : request.direction === 'down' ? amount : 0
150
+ if (request.ref !== undefined) {
151
+ const { element, role, name } = env.refs.resolve(request.ref)
152
+ ;(element as HTMLElement).scrollBy?.({ left: dx, top: dy })
153
+ if (typeof (element as HTMLElement).scrollBy !== 'function') { element.scrollTop += dy; element.scrollLeft += dx }
154
+ return { ok: true, target: target(role, name), detail: `scrolled ${request.direction}` }
155
+ }
156
+ const view = doc.defaultView
157
+ view?.scrollBy?.({ left: dx, top: dy })
158
+ return { ok: true, detail: `scrolled the page ${request.direction}` }
159
+ }
160
+
161
+ /** True when something visible on the page matches `request`. */
162
+ function present(env: ActionEnvironment, request: Request<'wait'>): boolean {
163
+ const doc = env.document()
164
+ const walk = (element: Element): boolean => {
165
+ if (element.hasAttribute(DRIVER_UI_ATTRIBUTE) || isHidden(element) || isSensitive(element)) return false
166
+ const role = roleOf(element)
167
+ if (request.role !== undefined || request.name !== undefined) {
168
+ if (role !== null && (request.role === undefined || role === request.role.toLowerCase()) && (request.name === undefined || nameOf(element, role).toLowerCase().includes(request.name.toLowerCase()))) return true
169
+ }
170
+ if (request.text !== undefined && element.children.length === 0 && (element.textContent ?? '').toLowerCase().includes(request.text.toLowerCase())) return true
171
+ return Array.from(element.children).some(walk)
172
+ }
173
+ return walk(doc.body)
174
+ }
175
+
176
+ export async function wait(env: ActionEnvironment, request: Request<'wait'>, signal: AbortSignal): Promise<UiActionResult> {
177
+ const budget = Math.min(request.timeoutMs ?? 3000, MAX_WAIT_MS)
178
+ let waited = 0
179
+ const wantGone = request.gone === true
180
+ for (;;) {
181
+ if (present(env, request) !== wantGone) return { ok: true, detail: wantGone ? 'it is gone' : 'it is there' }
182
+ if (waited >= budget) throw new UiControlError('not-found', wantGone ? 'it did not go away in time' : 'it did not appear in time')
183
+ await env.sleep(POLL_MS, signal)
184
+ waited += POLL_MS
185
+ }
186
+ }