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,132 @@
1
+ /** Builds a bounded, redacted accessibility snapshot of the page, with refs. */
2
+
3
+ import { DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES, UiControlError, type UiNode, type UiSnapshot } from '../protocol.ts'
4
+ import { isHidden, levelOf, NAMED_BY_CONTENT, nameOf, roleOf, SHOWN_AS_VALUE, statesOf, TEXT_ROLE, textRun, valueOf } from './accessibility.ts'
5
+ import type { RefTable } from './ref-table.ts'
6
+ import { isProtected, isSensitive } from './sensitivity.ts'
7
+
8
+ /** Marks the driver's own UI (the indicator), which a snapshot never lists. */
9
+ export const DRIVER_UI_ATTRIBUTE = 'data-acryl-agent-control'
10
+
11
+ /** The most elements one snapshot considers, however large the page. */
12
+ const MAX_CANDIDATES = 5000
13
+
14
+
15
+ interface Candidate {
16
+ readonly element: Element
17
+ readonly role: string
18
+ readonly name: string
19
+ readonly depth: number
20
+ }
21
+
22
+ /** True when the element (with layout) is at least partly inside the window; unknown layout counts as inside. */
23
+ function inViewport(element: Element, view: Window | null): boolean {
24
+ if (view === null) return true
25
+ const rect = element.getBoundingClientRect()
26
+ if (rect.width === 0 && rect.height === 0) return true
27
+ return rect.bottom > 0 && rect.right > 0 && rect.top < view.innerHeight && rect.left < view.innerWidth
28
+ }
29
+
30
+ /**
31
+ * Every element with a role, and every run of visible text, in reading order. Text that is already a name (inside a button, a link, a
32
+ * heading, a label) is not listed again; a run is the text between two child elements, so `<p>Left: <b>24:59</b></p>` reads as
33
+ * `Left:` and `24:59`.
34
+ */
35
+ function collect(root: Element): Candidate[] {
36
+ const found: Candidate[] = []
37
+ const walk = (element: Element, depth: number, named: boolean): void => {
38
+ if (found.length >= MAX_CANDIDATES) return
39
+ if (element.hasAttribute(DRIVER_UI_ATTRIBUTE)) return
40
+ const tag = element.tagName.toLowerCase()
41
+ if (tag === 'script' || tag === 'style' || tag === 'template' || tag === 'noscript') return
42
+ if (isHidden(element)) return
43
+ // A secret or payment field is not listed, and neither is anything inside a marked area.
44
+ if (isSensitive(element)) return
45
+ const role = roleOf(element)
46
+ let childDepth = depth
47
+ if (role !== null) {
48
+ found.push({ element, role, name: nameOf(element, role), depth })
49
+ childDepth = depth + 1
50
+ }
51
+ const inName = named || tag === 'label' || (role !== null && (NAMED_BY_CONTENT.has(role) || SHOWN_AS_VALUE.has(role)))
52
+ let run = ''
53
+ const flush = (): void => {
54
+ const text = textRun(run)
55
+ run = ''
56
+ if (text !== '' && !inName && found.length < MAX_CANDIDATES) found.push({ element, role: TEXT_ROLE, name: text, depth: childDepth })
57
+ }
58
+ for (const child of Array.from(element.childNodes)) {
59
+ if (child.nodeType === Node.TEXT_NODE) run += child.textContent ?? ''
60
+ else if (child instanceof Element) {
61
+ flush()
62
+ walk(child, childDepth, inName)
63
+ }
64
+ }
65
+ flush()
66
+ }
67
+ walk(root, 0, false)
68
+ return found
69
+ }
70
+
71
+ export interface SnapshotOptions {
72
+ readonly cursor?: number
73
+ readonly maxNodes?: number
74
+ }
75
+
76
+ /** Snapshots the page and remembers the full candidate list of the current generation, for paging. */
77
+ export class SnapshotBuilder {
78
+ private candidates: Candidate[] = []
79
+
80
+ private readonly refs: RefTable
81
+ private readonly root: () => Element
82
+
83
+ constructor(refs: RefTable, root: () => Element) {
84
+ this.refs = refs
85
+ this.root = root
86
+ }
87
+
88
+ snapshot(options: SnapshotOptions = {}): UiSnapshot {
89
+ const max = Math.min(options.maxNodes ?? DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES)
90
+ const root = this.root()
91
+ const view = root.ownerDocument.defaultView
92
+ let offset = options.cursor ?? 0
93
+ if (options.cursor === undefined) {
94
+ this.refs.begin()
95
+ const all = collect(root)
96
+ // What the user can see comes first, so a long page is cut from the bottom, not the middle of the window.
97
+ this.candidates = [...all.filter(c => inViewport(c.element, view)), ...all.filter(c => !inViewport(c.element, view))]
98
+ offset = 0
99
+ } else if (this.candidates.length === 0 || offset > this.candidates.length) {
100
+ throw new UiControlError('stale-ref', 'that page of the snapshot is gone; take a new snapshot')
101
+ }
102
+ const page = this.candidates.slice(offset, offset + max)
103
+ const nodes: UiNode[] = page.map((candidate) => {
104
+ const states = statesOf(candidate.element, candidate.role)
105
+ if (isProtected(candidate.element)) states.push('protected')
106
+ const value = valueOf(candidate.element, candidate.role)
107
+ const level = candidate.role === 'heading' ? levelOf(candidate.element) : undefined
108
+ return {
109
+ ref: this.refs.issue(candidate.element, candidate.role, candidate.name),
110
+ role: candidate.role,
111
+ name: candidate.name,
112
+ depth: candidate.depth,
113
+ states,
114
+ ...(value === undefined ? {} : { value }),
115
+ ...(level === undefined ? {} : { level }),
116
+ }
117
+ })
118
+ const end = offset + page.length
119
+ return {
120
+ generation: this.refs.generation,
121
+ title: root.ownerDocument.title,
122
+ nodes,
123
+ total: this.candidates.length,
124
+ ...(end < this.candidates.length ? { nextCursor: end } : {}),
125
+ }
126
+ }
127
+
128
+ /** Drop the remembered page list (with the refs). */
129
+ clear(): void {
130
+ this.candidates = []
131
+ }
132
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The door's page module (`main.ts` starts it; `<script type="module" src="/api/acryl-door/page.js"></script>` loads that). It drives this
3
+ * page's document for the agent (snapshots with refs, clicks, typing), answers the Host over the channel, and shows the indicator with
4
+ * the kill switch. It touches only this document, never another window and never the OS.
5
+ */
6
+
7
+ import { PageChannel } from './channel.ts'
8
+ import { UiDriver } from './driver/driver.ts'
9
+ import { mountIndicator } from './indicator.ts'
10
+ import { UserInputClock } from './user-input.ts'
11
+
12
+ export const DOOR_BASE = '/api/acryl-door'
13
+
14
+ /** Start the door on `document`. @returns the disposer. */
15
+ export function startDoor(document: Document, base: string = DOOR_BASE): () => void {
16
+ const view = document.defaultView
17
+ if (view === null) throw new Error('acryl-door: the document has no window')
18
+ const clock = new UserInputClock()
19
+ const driver = new UiDriver({ document: () => document, msSinceUserInput: clock.msSince })
20
+ const windowId = typeof view.crypto?.randomUUID === 'function' ? view.crypto.randomUUID() : `w-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`
21
+ const focused = (): boolean => document.visibilityState === 'visible' && document.hasFocus()
22
+ const detachInput = clock.attach(document)
23
+ const removeIndicator = mountIndicator(driver, document)
24
+ const channel = new PageChannel(driver, { windowId, focused, base })
25
+ const reportFocus = (): void => { channel.reportFocus(focused()) }
26
+ view.addEventListener('focus', reportFocus)
27
+ view.addEventListener('blur', reportFocus)
28
+ document.addEventListener('visibilitychange', reportFocus)
29
+ channel.connect()
30
+ return () => {
31
+ view.removeEventListener('focus', reportFocus)
32
+ view.removeEventListener('blur', reportFocus)
33
+ document.removeEventListener('visibilitychange', reportFocus)
34
+ channel.dispose()
35
+ removeIndicator()
36
+ detachInput()
37
+ // Pending calls settle as `unloaded` and every ref goes stale.
38
+ driver.dispose()
39
+ }
40
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * "The agent is driving" bar with its kill switch, in plain DOM (the React one in `acryl-agent-control` shows the same states). Shown while
3
+ * a call runs and after the person stopped the agent. It is marked as driver UI (so snapshots skip it) and as off limits to the agent (so
4
+ * it can never press its own Stop or Allow buttons). Escape stops the agent too, while it is working.
5
+ */
6
+
7
+ import type { DriverActivity, UiDriver } from './driver/driver.ts'
8
+
9
+ const LABELS: Record<string, string> = { snapshot: 'looking at the page', click: 'clicking', type: 'typing', select: 'choosing an option', press: 'pressing a key', scroll: 'scrolling', wait: 'waiting' }
10
+
11
+ const STYLE_ID = 'acryl-door-indicator-style'
12
+ const STYLES = `
13
+ .acrylDoorBar { position: fixed; z-index: 2147483647; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px;
14
+ padding: 8px 12px; border-radius: 8px; font: 13px/1.4 system-ui, sans-serif; color: #fff; background: #1f2937; box-shadow: 0 4px 16px rgba(0,0,0,.25); }
15
+ .acrylDoorBar[data-killed] { background: #7f1d1d; }
16
+ .acrylDoorBar button { font: inherit; color: inherit; background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.35); border-radius: 6px; padding: 2px 10px; cursor: pointer; }
17
+ .acrylDoorDot { width: 8px; height: 8px; border-radius: 50%; background: #34d399; }
18
+ `
19
+
20
+ /** Show the bar for `driver` in `document`. @returns the disposer, which removes the bar, its style and its listeners. */
21
+ export function mountIndicator(driver: UiDriver, document: Document): () => void {
22
+ if (document.getElementById(STYLE_ID) === null) {
23
+ const style = document.createElement('style')
24
+ style.id = STYLE_ID
25
+ style.textContent = STYLES
26
+ document.head.append(style)
27
+ }
28
+ const bar = document.createElement('div')
29
+ bar.className = 'acrylDoorBar'
30
+ bar.setAttribute('data-acryl-agent-control', '')
31
+ bar.setAttribute('data-acryl-no-agent', '')
32
+ bar.setAttribute('role', 'status')
33
+ bar.setAttribute('aria-live', 'polite')
34
+
35
+ const onKey = (event: KeyboardEvent): void => {
36
+ if (event.key === 'Escape' && event.isTrusted && driver.activity.busy) driver.kill()
37
+ }
38
+ document.addEventListener('keydown', onKey, true)
39
+
40
+ const render = (activity: DriverActivity): void => {
41
+ if (!activity.busy && !activity.killed) { bar.remove(); return }
42
+ const button = document.createElement('button')
43
+ button.type = 'button'
44
+ const text = document.createElement('span')
45
+ if (activity.killed) {
46
+ bar.setAttribute('data-killed', '')
47
+ text.textContent = 'Agent stopped. It cannot use the page until you allow it.'
48
+ button.textContent = 'Allow again'
49
+ button.addEventListener('click', () => { driver.resume() })
50
+ bar.replaceChildren(text, button)
51
+ } else {
52
+ bar.removeAttribute('data-killed')
53
+ const dot = document.createElement('span')
54
+ dot.className = 'acrylDoorDot'
55
+ dot.setAttribute('aria-hidden', 'true')
56
+ text.textContent = `Agent is driving: ${LABELS[activity.doing] ?? activity.doing}`
57
+ button.textContent = 'Stop'
58
+ button.addEventListener('click', () => { driver.kill() })
59
+ bar.replaceChildren(dot, text, button)
60
+ }
61
+ if (!bar.isConnected) document.body.append(bar)
62
+ }
63
+ const unsubscribe = driver.subscribe(render)
64
+ render(driver.activity)
65
+
66
+ return () => {
67
+ unsubscribe()
68
+ document.removeEventListener('keydown', onKey, true)
69
+ bar.remove()
70
+ document.getElementById(STYLE_ID)?.remove()
71
+ }
72
+ }
@@ -0,0 +1,7 @@
1
+ /** The page's script: starts the door once on this document, even if the script tag appears twice. */
2
+
3
+ import { startDoor } from './index.ts'
4
+
5
+ const flag = '__acrylDoor'
6
+ const host = globalThis as { [flag]?: () => void }
7
+ if (host[flag] === undefined) host[flag] = startDoor(document)
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The one shape of a door call, shared by the Host and the in-page driver (moved from `acryl-agent-control`, spec 041).
3
+ *
4
+ * The Host turns a verb's arguments into a {@link UiRequest}, sends it to the page over the channel, and gets a {@link UiResult} back.
5
+ * Everything crossing the channel is validated here once, so neither side trusts the other's JSON. The page imports this file too, so it
6
+ * lives with the page code and has no Node imports.
7
+ */
8
+
9
+ /** The operations the driver performs on the page. */
10
+ export const UI_OPS = ['snapshot', 'click', 'type', 'select', 'press', 'scroll', 'wait'] as const
11
+ export type UiOp = (typeof UI_OPS)[number]
12
+
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']
15
+
16
+ export const MAX_TEXT_LENGTH = 10_000
17
+ export const MAX_WAIT_MS = 10_000
18
+ export const MAX_SNAPSHOT_NODES = 1000
19
+ export const DEFAULT_SNAPSHOT_NODES = 300
20
+
21
+ /** Why a call did not do what was asked. Every failure is one of these, never a silent wrong action. */
22
+ export type UiErrorCode =
23
+ | 'no-window' // no page of the app is connected
24
+ | 'stale-ref' // the element changed or was replaced since the snapshot
25
+ | 'unknown-ref' // the ref was never issued
26
+ | 'sensitive' // a password, token, API key or payment field
27
+ | 'protected' // a control the agent may never operate (policy, approval, this plugin)
28
+ | 'not-actionable' // hidden, disabled, or the wrong kind of element for the action
29
+ | 'not-found' // a wait ended without the thing appearing
30
+ | 'killed' // the user pressed the kill switch
31
+ | 'timeout' // the page did not answer in time
32
+ | 'aborted' // the call was cancelled
33
+ | 'unloaded' // the driver went away mid-call
34
+ | 'disconnected' // the page's channel dropped while the call was in flight
35
+ | 'invalid' // malformed request
36
+
37
+ export class UiControlError extends Error {
38
+ readonly code: UiErrorCode
39
+
40
+ constructor(code: UiErrorCode, message: string) {
41
+ super(message)
42
+ this.code = code
43
+ this.name = 'UiControlError'
44
+ }
45
+ }
46
+
47
+ /** One element in a snapshot. */
48
+ export interface UiNode {
49
+ /** `<generation>.<n>`; valid only until the next snapshot. */
50
+ readonly ref: string
51
+ readonly role: string
52
+ readonly name: string
53
+ readonly depth: number
54
+ /** Short state words: `disabled`, `checked`, `expanded`, `selected`, `focused`, `protected`, ... */
55
+ readonly states: readonly string[]
56
+ /** The text of an editable field, when it is not sensitive. */
57
+ readonly value?: string
58
+ /** Heading level, when it is a heading. */
59
+ readonly level?: number
60
+ }
61
+
62
+ export interface UiSnapshot {
63
+ readonly generation: number
64
+ readonly title: string
65
+ readonly nodes: readonly UiNode[]
66
+ /** How many elements matched in all; more than `nodes.length` when the page was cut. */
67
+ readonly total: number
68
+ /** Pass to the next snapshot call to continue, or absent when this is everything. */
69
+ readonly nextCursor?: number
70
+ }
71
+
72
+ export type UiRequest =
73
+ | { readonly op: 'snapshot'; readonly cursor?: number; readonly maxNodes?: number }
74
+ | { readonly op: 'click'; readonly ref: string }
75
+ | { readonly op: 'type'; readonly ref: string; readonly text: string; readonly submit?: boolean; readonly clear?: boolean }
76
+ | { readonly op: 'select'; readonly ref: string; readonly option: string }
77
+ | { readonly op: 'press'; readonly key: string; readonly ref?: string }
78
+ | { readonly op: 'scroll'; readonly direction: 'up' | 'down' | 'left' | 'right'; readonly ref?: string; readonly amount?: number }
79
+ | { readonly op: 'wait'; readonly text?: string; readonly role?: string; readonly name?: string; readonly gone?: boolean; readonly timeoutMs?: number }
80
+
81
+ /** What the page did, and to which control (for the audit log and the approval prompt). */
82
+ export interface UiActionResult {
83
+ readonly ok: true
84
+ readonly target?: { readonly role: string; readonly name: string }
85
+ readonly detail?: string
86
+ }
87
+
88
+ export type UiResult = UiSnapshot | UiActionResult
89
+
90
+ /**
91
+ * Messages the page and the Host exchange over the channel. The Host sends calls down the page's event stream (`event: call`); the page
92
+ * opens that stream with its window id and focus, and sends everything else up by POST, naming its window id.
93
+ */
94
+ export type ChannelToPage =
95
+ | { readonly t: 'call'; readonly id: number; readonly request: UiRequest }
96
+ export type ChannelToHost =
97
+ | { readonly t: 'focus'; readonly focused: boolean }
98
+ | { readonly t: 'result'; readonly id: number; readonly value: UiResult }
99
+ | { readonly t: 'error'; readonly id: number; readonly code: UiErrorCode; readonly message: string }
100
+
101
+ const KEYS = /^(?:[A-Za-z0-9]|Enter|Escape|Tab|Backspace|Delete|Space|Arrow(?:Up|Down|Left|Right)|Home|End|PageUp|PageDown)$/
102
+ const REF = /^\d{1,9}\.\d{1,6}$/
103
+
104
+ const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value)
105
+ const invalid = (message: string): never => { throw new UiControlError('invalid', message) }
106
+
107
+ function ref(value: unknown): string {
108
+ if (typeof value !== 'string' || !REF.test(value)) return invalid('ref must look like 3.17, from the latest snapshot')
109
+ return value
110
+ }
111
+
112
+ function text(value: unknown, max = MAX_TEXT_LENGTH): string {
113
+ if (typeof value !== 'string' || value.length > max || value.includes('\0')) return invalid(`text must be a string of at most ${String(max)} characters`)
114
+ return value
115
+ }
116
+
117
+ /** @param value - untrusted JSON. @throws UiControlError `invalid`. */
118
+ export function parseUiRequest(value: unknown): UiRequest {
119
+ if (!isRecord(value) || typeof value.op !== 'string') return invalid('a call needs an op')
120
+ const op = value.op as UiOp
121
+ const only = (allowed: readonly string[]): void => {
122
+ const extra = Object.keys(value).find(key => key !== 'op' && !allowed.includes(key))
123
+ if (extra !== undefined) invalid(`unknown field ${extra}`)
124
+ }
125
+ switch (op) {
126
+ case 'snapshot': {
127
+ only(['cursor', 'maxNodes'])
128
+ const cursor = value.cursor === undefined ? undefined : Number(value.cursor)
129
+ const maxNodes = value.maxNodes === undefined ? undefined : Number(value.maxNodes)
130
+ if ((cursor !== undefined && (!Number.isInteger(cursor) || cursor < 0)) || (maxNodes !== undefined && (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > MAX_SNAPSHOT_NODES))) return invalid('cursor and maxNodes must be small positive integers')
131
+ return { op, ...(cursor === undefined ? {} : { cursor }), ...(maxNodes === undefined ? {} : { maxNodes }) }
132
+ }
133
+ case 'click': only(['ref']); return { op, ref: ref(value.ref) }
134
+ case 'type': {
135
+ only(['ref', 'text', 'submit', 'clear'])
136
+ if ((value.submit !== undefined && typeof value.submit !== 'boolean') || (value.clear !== undefined && typeof value.clear !== 'boolean')) return invalid('submit and clear must be true or false')
137
+ return { op, ref: ref(value.ref), text: text(value.text), ...(value.submit === undefined ? {} : { submit: value.submit }), ...(value.clear === undefined ? {} : { clear: value.clear }) }
138
+ }
139
+ case 'select': only(['ref', 'option']); return { op, ref: ref(value.ref), option: text(value.option, 500) }
140
+ case 'press': {
141
+ only(['key', 'ref'])
142
+ if (typeof value.key !== 'string' || !KEYS.test(value.key)) return invalid('key must be a single character or a name such as Enter, Escape, Tab or ArrowDown')
143
+ return { op, key: value.key, ...(value.ref === undefined ? {} : { ref: ref(value.ref) }) }
144
+ }
145
+ case 'scroll': {
146
+ only(['direction', 'ref', 'amount'])
147
+ if (value.direction !== 'up' && value.direction !== 'down' && value.direction !== 'left' && value.direction !== 'right') return invalid('direction must be up, down, left or right')
148
+ const amount = value.amount === undefined ? undefined : Number(value.amount)
149
+ if (amount !== undefined && (!Number.isFinite(amount) || amount <= 0 || amount > 10_000)) return invalid('amount must be between 1 and 10000 pixels')
150
+ return { op, direction: value.direction, ...(value.ref === undefined ? {} : { ref: ref(value.ref) }), ...(amount === undefined ? {} : { amount }) }
151
+ }
152
+ case 'wait': {
153
+ only(['text', 'role', 'name', 'gone', 'timeoutMs'])
154
+ const timeoutMs = value.timeoutMs === undefined ? undefined : Number(value.timeoutMs)
155
+ if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > MAX_WAIT_MS)) return invalid(`timeoutMs must be between 0 and ${String(MAX_WAIT_MS)}`)
156
+ if (value.gone !== undefined && typeof value.gone !== 'boolean') return invalid('gone must be true or false')
157
+ if (value.text === undefined && value.role === undefined && value.name === undefined) return invalid('wait needs text, or a role and name')
158
+ return {
159
+ op,
160
+ ...(value.text === undefined ? {} : { text: text(value.text, 500) }),
161
+ ...(value.role === undefined ? {} : { role: text(value.role, 60) }),
162
+ ...(value.name === undefined ? {} : { name: text(value.name, 500) }),
163
+ ...(value.gone === undefined ? {} : { gone: value.gone }),
164
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
165
+ }
166
+ }
167
+ default: return invalid(`unknown op ${String(op)}`)
168
+ }
169
+ }
170
+
171
+ /** @param text - one `call` event's data from the Host. @throws when it is not a well-formed call. */
172
+ export function parseChannelToPage(text: string): ChannelToPage {
173
+ const value: unknown = JSON.parse(text)
174
+ if (isRecord(value) && value.t === 'call' && typeof value.id === 'number' && Number.isSafeInteger(value.id)) {
175
+ return { t: 'call', id: value.id, request: parseUiRequest(value.request) }
176
+ }
177
+ throw new UiControlError('invalid', 'not a channel call')
178
+ }
179
+
180
+ /** Every code a {@link UiControlError} can carry, so a code read off the wire is validated, not cast. */
181
+ export const UI_ERROR_CODES: readonly UiErrorCode[] = ['no-window', 'stale-ref', 'unknown-ref', 'sensitive', 'protected', 'not-actionable', 'not-found', 'killed', 'timeout', 'aborted', 'unloaded', 'disconnected', 'invalid']
182
+
183
+ /** A window id the page chose: short, printable, so it can travel in a query string and a log line. */
184
+ export const WINDOW_ID = /^[A-Za-z0-9-]{1,80}$/u
185
+
186
+ /** @param value - one parsed message from the page; null when it is not a well-formed message. */
187
+ export function parseChannelToHost(value: unknown): ChannelToHost | null {
188
+ if (!isRecord(value)) return null
189
+ if (value.t === 'focus' && typeof value.focused === 'boolean') return { t: 'focus', focused: value.focused }
190
+ if (value.t === 'result' && typeof value.id === 'number' && isRecord(value.value)) return { t: 'result', id: value.id, value: value.value as unknown as UiResult }
191
+ if (value.t === 'error' && typeof value.id === 'number' && typeof value.message === 'string' && UI_ERROR_CODES.includes(value.code as UiErrorCode)) {
192
+ return { t: 'error', id: value.id, code: value.code as UiErrorCode, message: value.message.slice(0, 500) }
193
+ }
194
+ return null
195
+ }
196
+
197
+ /** One line of the audit log: what the agent did, never what it typed. */
198
+ export interface AuditEntry {
199
+ /** ISO time of the call. */
200
+ readonly at: string
201
+ readonly tool: string
202
+ /** The control touched, when the call had one. */
203
+ readonly target?: { readonly role: string; readonly name: string }
204
+ readonly outcome: 'ok' | 'refused' | 'failed'
205
+ /** The error code for a refusal or failure, or a short note for a success. */
206
+ readonly detail?: string
207
+ /**
208
+ * Whether the user was asked first (`asked`), approval was switched off (`none`), the call needed no
209
+ * approval (`not-needed`), or it came from an operator holding this run's secret (`token`, spec 041 TB30): authorized by the secret,
210
+ * which only the OS user who started the app can read, and watched by the person at the page through the indicator and its kill switch.
211
+ */
212
+ readonly approval: 'asked' | 'none' | 'not-needed' | 'token'
213
+ }
@@ -0,0 +1,18 @@
1
+ /** How a snapshot reads to an agent. Pure text, shared by the Host and the tests (no DOM). */
2
+
3
+ import type { UiSnapshot } from './protocol.ts'
4
+
5
+ /**
6
+ * The snapshot as compact text for an agent: one line per element, indented by depth.
7
+ * @param more - how to ask for the rest when the page was cut, given the cursor to continue from.
8
+ */
9
+ export function renderSnapshot(snapshot: UiSnapshot, more: (cursor: number) => string = cursor => `call ui_snapshot with cursor ${String(cursor)}`): string {
10
+ const lines = snapshot.nodes.map((node) => {
11
+ const level = node.level === undefined ? '' : ` level=${String(node.level)}`
12
+ const states = node.states.length === 0 ? '' : ` [${node.states.join(', ')}]`
13
+ const value = node.value === undefined ? '' : ` value=${JSON.stringify(node.value)}`
14
+ return `${' '.repeat(Math.min(node.depth, 8))}- ${node.role}${node.name === '' ? '' : ` ${JSON.stringify(node.name)}`}${level}${states}${value} [ref=${node.ref}]`
15
+ })
16
+ const rest = snapshot.nextCursor === undefined ? '' : `\n(${String(snapshot.total - snapshot.nextCursor)} more elements; ${more(snapshot.nextCursor)})`
17
+ return `Page: ${snapshot.title || '(untitled)'}\nSnapshot ${String(snapshot.generation)}, ${String(snapshot.nodes.length)} of ${String(snapshot.total)} elements:\n${lines.join('\n')}${rest}`
18
+ }
@@ -0,0 +1,24 @@
1
+ /** Tracks when the user last used the page, so the agent yields to them. */
2
+
3
+ /** Events only a real person produces: a script's `dispatchEvent` is never `isTrusted`. */
4
+ const USER_EVENTS = ['pointerdown', 'mousedown', 'keydown', 'wheel', 'touchstart'] as const
5
+
6
+ export class UserInputClock {
7
+ private last = Number.NEGATIVE_INFINITY
8
+
9
+ private readonly now: () => number
10
+
11
+ constructor(now: () => number = () => Date.now()) {
12
+ this.now = now
13
+ }
14
+
15
+ /** Milliseconds since the user last touched the page; Infinity when they have not. */
16
+ msSince = (): number => (this.last === Number.NEGATIVE_INFINITY ? Number.POSITIVE_INFINITY : this.now() - this.last)
17
+
18
+ /** Start listening. @returns disposer. */
19
+ attach(target: Document): () => void {
20
+ const handler = (event: Event): void => { if (event.isTrusted) this.last = this.now() }
21
+ for (const type of USER_EVENTS) target.addEventListener(type, handler, { capture: true, passive: true })
22
+ return () => { for (const type of USER_EVENTS) target.removeEventListener(type, handler, { capture: true }) }
23
+ }
24
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "path": "plugins/acryl-door",
5
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
6
+ },
7
+ "vendored": [
8
+ "src",
9
+ "tests",
10
+ "LICENSE"
11
+ ],
12
+ "rewrittenSpecifiers": 13,
13
+ "digest": "sha256:90f0731ebb2e6d8a7c29e6f1e6f250cb2eb719b6622e5d9e5655281947584d6c",
14
+ "testsNotRun": {
15
+ "tests/page/accessible-name-hidden.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
16
+ "tests/page/channel.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
17
+ "tests/page/driver.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
18
+ "tests/page/indicator.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
19
+ "tests/page/visible-text.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
20
+ "tests/host/door.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
21
+ "tests/host/verbs.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
22
+ "tests/host/shim.spec.ts": "needs jsdom, a development dependency of ACRYL that generated apps do not install; it runs in ACRYL, and verify:scaffolder walks the door in a real browser",
23
+ "tests/host/loader.spec.ts": "loads the plugin through the real Loader (@deepseek-ai/cordis-plugin-loader), which generated apps do not install"
24
+ },
25
+ "notice": [
26
+ "LICENSE"
27
+ ]
28
+ }
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-inspect",
5
- "commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
5
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -7,6 +7,11 @@
7
7
  "dependencies": {
8
8
  "@deepseek-ai/cordis": "4.0.4"
9
9
  },
10
- "mount": "it is the port, not a service: a plugin declares inject: ['kv'] and imports the ctx.kv type from './plugins/acryl-kv/src/cordis.ts'; add acryl-kv-json (a file) or acryl-kv-db (your SQL database) to provide it",
11
- "omit": ["src/conformance.ts", "**/*.test.*", "**/*.spec.*", "**/*.case.ts"]
10
+ "mount": "it is the port, not a service: a plugin declares inject: ['kv'] and imports the ctx.kv type from '../plugins/acryl-kv/src/cordis.ts'; add acryl-kv-json (a file) or acryl-kv-db (your SQL database) to provide it",
11
+ "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
12
+ "imports": {
13
+ "#acryl-kv": "acryl-kv/src/index.ts",
14
+ "#acryl-kv/conformance": "acryl-kv/src/conformance.ts"
15
+ },
16
+ "suite": "src/conformance.ts"
12
17
  }