acrylblends 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/dist/index.d.ts +14 -6
  2. package/dist/index.js +130 -31
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +3 -3
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/agents/index.md +18 -3
  7. package/docs/framework/getting-started/basic.md +1 -2
  8. package/docs/framework/getting-started/desktop.md +3 -3
  9. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  10. package/docs/framework/guides/client-modules.md +4 -3
  11. package/docs/framework/guides/create-acrylblends.md +5 -4
  12. package/docs/framework/guides/data.md +7 -6
  13. package/docs/framework/helpers/streaming.md +12 -27
  14. package/docs/framework/upstream.json +3 -3
  15. package/lib/browser/read-sse.js +84 -0
  16. package/lib/db/sqlite.d.ts +1 -1
  17. package/lib/db.d.ts +1 -84
  18. package/lib/index-BLTYsyc1.d.ts +93 -0
  19. package/lib/index-D4FAsBZu.d.ts +85 -0
  20. package/lib/kv/db.d.ts +2 -2
  21. package/lib/kv/json.d.ts +1 -1
  22. package/lib/kv/json.js +87 -24
  23. package/lib/kv.d.ts +1 -92
  24. package/lib/node-server.d.ts +3 -3
  25. package/lib/node-server.js +14 -8
  26. package/lib/read-sse.d.ts +33 -0
  27. package/lib/read-sse.js +84 -0
  28. package/package.json +5 -1
  29. package/plugins/acryl-db/plugin.json +7 -2
  30. package/plugins/acryl-db/src/conformance.ts +224 -0
  31. package/plugins/acryl-db/upstream.json +2 -2
  32. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  33. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  34. package/plugins/acryl-door/LICENSE +47 -0
  35. package/plugins/acryl-door/plugin.json +14 -0
  36. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  37. package/plugins/acryl-door/src/host/api.ts +9 -0
  38. package/plugins/acryl-door/src/host/audit.ts +64 -0
  39. package/plugins/acryl-door/src/host/channel.ts +142 -0
  40. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  41. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  42. package/plugins/acryl-door/src/host/door.ts +195 -0
  43. package/plugins/acryl-door/src/host/http.ts +80 -0
  44. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  45. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  46. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  47. package/plugins/acryl-door/src/host/shim.ts +65 -0
  48. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  49. package/plugins/acryl-door/src/index.ts +133 -0
  50. package/plugins/acryl-door/src/page/api.ts +9 -0
  51. package/plugins/acryl-door/src/page/channel.ts +135 -0
  52. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  53. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  54. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  55. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  56. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  57. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  58. package/plugins/acryl-door/src/page/index.ts +40 -0
  59. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  60. package/plugins/acryl-door/src/page/main.ts +7 -0
  61. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  62. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  63. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  64. package/plugins/acryl-door/upstream.json +28 -0
  65. package/plugins/acryl-inspect/upstream.json +1 -1
  66. package/plugins/acryl-kv/plugin.json +7 -2
  67. package/plugins/acryl-kv/src/conformance.ts +226 -0
  68. package/plugins/acryl-kv/upstream.json +1 -1
  69. package/plugins/acryl-kv-db/plugin.json +4 -1
  70. package/plugins/acryl-kv-db/upstream.json +1 -1
  71. package/plugins/acryl-kv-json/plugin.json +4 -1
  72. package/plugins/acryl-kv-json/src/open.ts +86 -22
  73. package/plugins/acryl-kv-json/upstream.json +2 -2
  74. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  75. package/plugins/acryl-node-server/src/client-modules.ts +10 -9
  76. package/plugins/acryl-node-server/upstream.json +2 -2
  77. package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
  78. package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
  79. package/plugins/acryl-router/src/service.ts +3 -0
  80. package/plugins/acryl-router/upstream.json +2 -2
  81. package/plugins/acryl-router-http/upstream.json +1 -1
  82. package/plugins/acryl-state/upstream.json +1 -1
  83. package/plugins/agent-panel/client/main.ts +159 -0
  84. package/plugins/agent-panel/index.ts +232 -0
  85. package/plugins/agent-panel/plugin.json +21 -0
  86. package/plugins/builder/index.ts +2 -2
  87. package/plugins/builder/plugin.json +1 -1
  88. package/plugins/desktop/main.ts +3 -3
  89. package/plugins/desktop/plugin.json +2 -2
  90. package/plugins/desktop/upstream.json +1 -1
  91. package/plugins/desktop-web/main.ts +2 -2
  92. package/plugins/desktop-web/plugin.json +2 -2
  93. package/plugins/desktop-web/upstream.json +1 -1
  94. package/plugins/extensions/index.ts +19 -1
  95. package/plugins/extensions/plugin.json +1 -1
  96. package/plugins/extensions/resolve.ts +17 -3
  97. package/plugins/manifest/lock.ts +7 -3
  98. package/plugins/manifest/upgrade-cli.ts +1 -1
  99. package/plugins/save/cli.ts +1 -1
  100. package/plugins/save/plugin.json +1 -1
  101. package/plugins/tsconfig.json +6 -0
  102. package/shared/AGENTS.core.md +10 -10
  103. package/shared/DEFAULTS.md +45 -0
  104. package/shared/SKILL.md +14 -0
  105. package/templates/agent/AGENTS.md +1 -1
  106. package/templates/agent/README.md +6 -6
  107. package/templates/agent/blend.yaml +3 -0
  108. package/templates/agent/gitignore +1 -0
  109. package/templates/agent/package.json +8 -8
  110. package/templates/agent/src/app.ts +46 -17
  111. package/templates/agent/src/main.ts +2 -2
  112. package/templates/agent/src/routes.ts +19 -6
  113. package/templates/agent/template.json +2 -1
  114. package/templates/agent/test/agent.test.ts +11 -5
  115. package/templates/agent/test/app.test.ts +30 -9
  116. package/templates/agent/test/builder.test.ts +35 -24
  117. package/templates/agent/test/model.test.ts +25 -20
  118. package/templates/agent/tsconfig.json +48 -50
  119. package/templates/blank/AGENTS.md +1 -1
  120. package/templates/blank/README.md +2 -2
  121. package/templates/blank/gitignore +1 -0
  122. package/templates/blank/package.json +8 -8
  123. package/templates/blank/src/about.tsx +2 -2
  124. package/templates/blank/src/app.ts +29 -9
  125. package/templates/blank/src/main.ts +1 -1
  126. package/templates/blank/src/routes.ts +1 -1
  127. package/templates/blank/test/app.test.ts +29 -10
  128. package/templates/blank/tsconfig.json +1 -1
  129. package/templates/chat/AGENTS.md +16 -0
  130. package/templates/chat/blend.yaml +29 -0
  131. package/templates/chat/gitignore +6 -0
  132. package/templates/chat/migrations/001-chat.sql +12 -0
  133. package/templates/chat/package.json +33 -0
  134. package/templates/chat/src/app.ts +106 -0
  135. package/templates/chat/src/chat-app.ts +101 -0
  136. package/templates/chat/src/client/chat.tsx +115 -0
  137. package/templates/chat/src/client/tsconfig.json +26 -0
  138. package/templates/chat/src/db.ts +43 -0
  139. package/templates/chat/src/door.ts +7 -0
  140. package/templates/chat/src/llm.ts +42 -0
  141. package/templates/chat/src/main.ts +28 -0
  142. package/templates/chat/src/page.tsx +40 -0
  143. package/templates/chat/src/routes.ts +15 -0
  144. package/templates/chat/src/runtime.ts +17 -0
  145. package/templates/chat/template.json +11 -0
  146. package/templates/chat/test/chat.test.ts +198 -0
  147. package/templates/chat/test/server.test.ts +78 -0
  148. package/templates/chat/tsconfig.json +20 -0
  149. package/templates/nodejs/AGENTS.md +9 -0
  150. package/templates/nodejs/blend.yaml +26 -0
  151. package/templates/nodejs/gitignore +3 -0
  152. package/templates/nodejs/package.json +30 -0
  153. package/templates/nodejs/src/app.ts +92 -0
  154. package/templates/nodejs/src/client/counter.tsx +12 -0
  155. package/templates/nodejs/src/client/tsconfig.json +26 -0
  156. package/templates/nodejs/src/door.ts +7 -0
  157. package/templates/nodejs/src/main.ts +25 -0
  158. package/templates/nodejs/src/page.tsx +29 -0
  159. package/templates/nodejs/src/routes.ts +40 -0
  160. package/templates/nodejs/template.json +3 -0
  161. package/templates/nodejs/test/app.test.ts +78 -0
  162. package/templates/nodejs/test/server.test.ts +83 -0
  163. package/templates/nodejs/tsconfig.json +20 -0
  164. package/templates/notes/AGENTS.md +26 -0
  165. package/templates/notes/blend.yaml +29 -0
  166. package/templates/notes/gitignore +6 -0
  167. package/templates/notes/migrations/001-notes.sql +18 -0
  168. package/templates/notes/package.json +33 -0
  169. package/templates/notes/src/app.ts +106 -0
  170. package/templates/notes/src/auth.ts +57 -0
  171. package/templates/notes/src/client/counter.tsx +21 -0
  172. package/templates/notes/src/client/tsconfig.json +26 -0
  173. package/templates/notes/src/db.ts +50 -0
  174. package/templates/notes/src/door.ts +7 -0
  175. package/templates/notes/src/main.ts +28 -0
  176. package/templates/notes/src/notes-app.ts +205 -0
  177. package/templates/notes/src/routes.ts +14 -0
  178. package/templates/notes/src/runtime.ts +17 -0
  179. package/templates/notes/src/schema.ts +38 -0
  180. package/templates/notes/src/views.tsx +136 -0
  181. package/templates/notes/template.json +11 -0
  182. package/templates/notes/test/notes.test.ts +280 -0
  183. package/templates/notes/test/server.test.ts +78 -0
  184. package/templates/notes/tsconfig.json +20 -0
  185. package/templates/todo/README.md +1 -1
  186. package/templates/todo/gitignore +1 -0
  187. package/templates/todo/package.json +8 -8
  188. package/templates/todo/src/app.ts +32 -12
  189. package/templates/todo/src/main.ts +1 -1
  190. package/templates/todo/src/todo.ts +4 -4
  191. package/templates/todo/test/todo.test.ts +48 -23
  192. package/templates/todo/tsconfig.json +1 -1
  193. package/templates/todo/upstream.json +2 -2
  194. package/lib/database-D_UN6We8.d.ts +0 -44
  195. package/lib/kv-DWDm0Lja.d.ts +0 -39
@@ -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
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The in-page driver: takes one {@link UiRequest} and does it, under the rules.
3
+ *
4
+ * It owns the ref table, the kill switch, the yield-to-the-user rule and the "driving" activity the indicator
5
+ * shows. It touches only the document it is given, never another window and never the OS.
6
+ */
7
+
8
+ import { UiControlError, MUTATING_OPS, type UiRequest, type UiResult } from '../protocol.ts'
9
+ import { click, pressKey, scroll, selectOption, typeText, wait, type ActionEnvironment } from './actions.ts'
10
+ import { RefTable } from './ref-table.ts'
11
+ import { SnapshotBuilder } from './snapshot.ts'
12
+
13
+ /** After the user touches the page, agent actions wait this long, so the user always wins. */
14
+ const YIELD_MS = 400
15
+ const MAX_YIELD_MS = 2000
16
+
17
+ export interface DriverActivity {
18
+ /** True from the moment a call starts until it ends. */
19
+ readonly busy: boolean
20
+ /** What the agent is doing, for the indicator ("click", "type", ...). */
21
+ readonly doing: string
22
+ /** True after the user pressed the kill switch. */
23
+ readonly killed: boolean
24
+ }
25
+
26
+ export interface UiDriverOptions {
27
+ readonly document: () => Document
28
+ /** Milliseconds since the user last used the page (mouse, keyboard); Infinity when never. */
29
+ readonly msSinceUserInput?: () => number
30
+ readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>
31
+ }
32
+
33
+ function realSleep(ms: number, signal: AbortSignal): Promise<void> {
34
+ return new Promise((resolve, reject) => {
35
+ if (signal.aborted) { reject(signal.reason); return }
36
+ const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve() }, ms)
37
+ const onAbort = (): void => { clearTimeout(timer); reject(signal.reason) }
38
+ signal.addEventListener('abort', onAbort, { once: true })
39
+ })
40
+ }
41
+
42
+ export class UiDriver {
43
+ private readonly refs = new RefTable()
44
+ private readonly snapshots: SnapshotBuilder
45
+ private readonly listeners = new Set<(activity: DriverActivity) => void>()
46
+ private readonly env: ActionEnvironment
47
+ private readonly stopAll = new Set<AbortController>()
48
+ private killedFlag = false
49
+ private disposed = false
50
+ private busyCount = 0
51
+ private doing = ''
52
+ private current: DriverActivity = { busy: false, doing: '', killed: false }
53
+
54
+ private readonly options: UiDriverOptions
55
+
56
+ constructor(options: UiDriverOptions) {
57
+ this.options = options
58
+ this.snapshots = new SnapshotBuilder(this.refs, () => options.document().body)
59
+ this.env = { refs: this.refs, document: options.document, sleep: options.sleep ?? realSleep }
60
+ }
61
+
62
+ /** The current activity; the same object until something changes, as a store snapshot must be. */
63
+ get activity(): DriverActivity {
64
+ return this.current
65
+ }
66
+
67
+ subscribe(listener: (activity: DriverActivity) => void): () => void {
68
+ this.listeners.add(listener)
69
+ return () => { this.listeners.delete(listener) }
70
+ }
71
+
72
+ /** The user pressed the kill switch: pending calls settle as `killed` and new ones are refused. */
73
+ kill(): void {
74
+ this.killedFlag = true
75
+ for (const controller of [...this.stopAll]) controller.abort(new UiControlError('killed', 'the user stopped the agent'))
76
+ this.refs.clear()
77
+ this.snapshots.clear()
78
+ this.emit()
79
+ }
80
+
81
+ /** The user allowed the agent to drive again. */
82
+ resume(): void {
83
+ this.killedFlag = false
84
+ this.emit()
85
+ }
86
+
87
+ /** The driver goes away: pending calls settle as `unloaded`; refs are gone. */
88
+ dispose(): void {
89
+ this.disposed = true
90
+ for (const controller of [...this.stopAll]) controller.abort(new UiControlError('unloaded', 'the driver was unloaded'))
91
+ this.refs.clear()
92
+ this.snapshots.clear()
93
+ this.listeners.clear()
94
+ }
95
+
96
+ /** @param signal - the Host cancelled the call. @throws UiControlError. */
97
+ async handle(request: UiRequest, signal?: AbortSignal): Promise<UiResult> {
98
+ if (this.disposed) throw new UiControlError('unloaded', 'the driver was unloaded')
99
+ if (this.killedFlag) throw new UiControlError('killed', 'the user stopped the agent; it cannot drive until they allow it again')
100
+ const controller = new AbortController()
101
+ this.stopAll.add(controller)
102
+ const onCancel = (): void => { controller.abort(new UiControlError('aborted', 'the call was cancelled')) }
103
+ signal?.addEventListener('abort', onCancel, { once: true })
104
+ this.busyCount += 1
105
+ this.doing = request.op
106
+ this.emit()
107
+ try {
108
+ if (MUTATING_OPS.includes(request.op)) await this.yieldToUser(controller.signal)
109
+ if (controller.signal.aborted) throw controller.signal.reason
110
+ return await this.run(request, controller.signal)
111
+ } catch (cause) {
112
+ if (cause instanceof UiControlError) throw cause
113
+ if (controller.signal.aborted && controller.signal.reason instanceof UiControlError) throw controller.signal.reason
114
+ throw new UiControlError('not-actionable', cause instanceof Error ? cause.message : 'the action failed')
115
+ } finally {
116
+ signal?.removeEventListener('abort', onCancel)
117
+ this.stopAll.delete(controller)
118
+ this.busyCount -= 1
119
+ if (this.busyCount === 0) this.doing = ''
120
+ this.emit()
121
+ }
122
+ }
123
+
124
+ private async run(request: UiRequest, signal: AbortSignal): Promise<UiResult> {
125
+ switch (request.op) {
126
+ case 'snapshot': return this.snapshots.snapshot({ ...(request.cursor === undefined ? {} : { cursor: request.cursor }), ...(request.maxNodes === undefined ? {} : { maxNodes: request.maxNodes }) })
127
+ case 'click': return click(this.env, request)
128
+ case 'type': return typeText(this.env, request)
129
+ case 'select': return selectOption(this.env, request)
130
+ case 'press': return pressKey(this.env, request)
131
+ case 'scroll': return scroll(this.env, request)
132
+ case 'wait': return wait(this.env, request, signal)
133
+ }
134
+ }
135
+
136
+ private async yieldToUser(signal: AbortSignal): Promise<void> {
137
+ const since = this.options.msSinceUserInput
138
+ if (since === undefined) return
139
+ let waited = 0
140
+ while (since() < YIELD_MS && waited < MAX_YIELD_MS) {
141
+ await this.env.sleep(100, signal)
142
+ waited += 100
143
+ }
144
+ }
145
+
146
+ private emit(): void {
147
+ const next: DriverActivity = { busy: this.busyCount > 0, doing: this.doing, killed: this.killedFlag }
148
+ if (next.busy === this.current.busy && next.doing === this.current.doing && next.killed === this.current.killed) return
149
+ this.current = next
150
+ const activity = next
151
+ for (const listener of [...this.listeners]) listener(activity)
152
+ }
153
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Refs: short-lived handles an agent uses to name an element.
3
+ *
4
+ * A ref is `<generation>.<n>`. Each snapshot starts a new generation and every older ref becomes stale, so a
5
+ * ref can never quietly point at a different element after the page changed. Even within a generation, a
6
+ * ref is honoured only while its element is still in the document and still has the role and name it had when
7
+ * the ref was issued; a re-rendered or replaced control is a typed `stale-ref`, never a click somewhere else.
8
+ */
9
+
10
+ import { UiControlError } from '../protocol.ts'
11
+ import { nameOf, roleOf, TEXT_ROLE, textRunsOf } from './accessibility.ts'
12
+
13
+ interface Entry {
14
+ readonly element: WeakRef<Element>
15
+ readonly role: string
16
+ readonly name: string
17
+ }
18
+
19
+ export interface ResolvedRef {
20
+ readonly element: Element
21
+ readonly role: string
22
+ readonly name: string
23
+ }
24
+
25
+ export class RefTable {
26
+ private generationNumber = 0
27
+ private entries = new Map<number, Entry>()
28
+ private nextIndex = 1
29
+
30
+ get generation(): number {
31
+ return this.generationNumber
32
+ }
33
+
34
+ /** Start a new generation: every earlier ref is now stale. */
35
+ begin(): number {
36
+ this.generationNumber += 1
37
+ this.entries = new Map()
38
+ this.nextIndex = 1
39
+ return this.generationNumber
40
+ }
41
+
42
+ /** Forget everything (the driver is going away or was stopped). */
43
+ clear(): void {
44
+ this.begin()
45
+ }
46
+
47
+ issue(element: Element, role: string, name: string): string {
48
+ const index = this.nextIndex
49
+ this.nextIndex += 1
50
+ this.entries.set(index, { element: new WeakRef(element), role, name })
51
+ return `${String(this.generationNumber)}.${String(index)}`
52
+ }
53
+
54
+ /** @throws UiControlError `unknown-ref` or `stale-ref`. */
55
+ resolve(ref: string): ResolvedRef {
56
+ const [generationText, indexText] = ref.split('.')
57
+ const generation = Number(generationText)
58
+ const index = Number(indexText)
59
+ if (!Number.isInteger(generation) || !Number.isInteger(index)) throw new UiControlError('unknown-ref', `${ref} is not a ref`)
60
+ if (generation !== this.generationNumber) {
61
+ throw new UiControlError('stale-ref', `${ref} is from an earlier snapshot (the latest is ${String(this.generationNumber)}); take a new snapshot`)
62
+ }
63
+ const entry = this.entries.get(index)
64
+ if (entry === undefined) throw new UiControlError('unknown-ref', `${ref} was not in the snapshot`)
65
+ const element = entry.element.deref()
66
+ if (element === undefined || !element.isConnected) throw new UiControlError('stale-ref', `${ref} is no longer on the page; take a new snapshot`)
67
+ // The same node can be reused for something else after a re-render: check it is still what was listed.
68
+ if (entry.role === TEXT_ROLE) {
69
+ if (!textRunsOf(element).includes(entry.name)) throw new UiControlError('stale-ref', `${ref} changed since the snapshot ("${entry.name}" is no longer there); take a new snapshot`)
70
+ return { element, role: entry.role, name: entry.name }
71
+ }
72
+ const role = roleOf(element)
73
+ if (role !== entry.role || nameOf(element, role) !== entry.name) {
74
+ throw new UiControlError('stale-ref', `${ref} changed since the snapshot ("${entry.name}" is now something else); take a new snapshot`)
75
+ }
76
+ return { element, role: entry.role, name: entry.name }
77
+ }
78
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What the agent may never see or type into, decided by rule and not by trusting the agent:
3
+ * secrets and payment fields (sensitive), and controls that govern the agent itself (protected).
4
+ */
5
+
6
+ /** Field names and labels that mean a secret or a payment detail. */
7
+ const SENSITIVE_WORDS = /pass(?:word|code|phrase)|secret|token|api[-_ ]?key|credential|private[-_ ]?key|\bpin\b|card|cvv|cvc|iban|routing|account[-_ ]?number|ssn|social[-_ ]?security|one[-_ ]?time|otp/i
8
+ /** `autocomplete` values that browsers treat as credentials or payment data. */
9
+ const SENSITIVE_AUTOCOMPLETE = /^(?:cc-|current-password|new-password|one-time-code)/
10
+
11
+ const label = (el: Element, attr: string): string => el.getAttribute(attr) ?? ''
12
+
13
+ /** True for a form field (or anything marked) that holds a secret or payment detail. */
14
+ export function isSensitive(el: Element): boolean {
15
+ if (el.closest('[data-acryl-sensitive]') !== null) return true
16
+ if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement)) return false
17
+ if (el instanceof HTMLInputElement && el.type === 'password') return true
18
+ const autocomplete = label(el, 'autocomplete').trim().toLowerCase()
19
+ if (autocomplete.split(/\s+/).some(token => SENSITIVE_AUTOCOMPLETE.test(token))) return true
20
+ return SENSITIVE_WORDS.test([el.name, el.id, label(el, 'placeholder'), label(el, 'aria-label')].join(' '))
21
+ }
22
+
23
+ /** Areas whose controls the agent may look at but never operate. */
24
+ const PROTECTED_REGION = /approval|permission|policy|agent control|ui control|acryl-agent-control|acryl-door|kill switch/i
25
+
26
+ /** The text an ancestor uses to name itself, for region rules. */
27
+ function ancestorName(el: Element): string {
28
+ const own = label(el, 'aria-label') || label(el, 'data-region') || ''
29
+ if (own !== '') return own
30
+ const labelled = label(el, 'aria-labelledby')
31
+ if (labelled !== '') {
32
+ return labelled.split(/\s+/).map(id => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' ')
33
+ }
34
+ return ''
35
+ }
36
+
37
+ /**
38
+ * True for a control the agent must not operate: inside `[data-acryl-no-agent]`, inside a region whose
39
+ * name says approval, permission, policy or Agent Control, or itself named after switching Agent Control off.
40
+ */
41
+ export function isProtected(el: Element): boolean {
42
+ if (el.closest('[data-acryl-no-agent]') !== null) return true
43
+ let node: Element | null = el
44
+ for (let depth = 0; node !== null && depth < 12; depth += 1) {
45
+ if (PROTECTED_REGION.test(ancestorName(node))) return true
46
+ node = node.parentElement
47
+ }
48
+ return false
49
+ }