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,38 @@
1
+ /**
2
+ * The operator's routes: an agent's shell on this machine, through `.acryl/bin/acryl`. Loopback only, with this run's secret.
3
+ *
4
+ * `POST <prefix>/call` takes `{ verb, args }` and answers {@link CallResponse}; `GET <prefix>/verbs` lists the verbs. A caller that goes
5
+ * away aborts its call through the request's signal.
6
+ */
7
+
8
+ import { Acryl } from '../../../acryl-router/src/index.ts'
9
+ import { UiControlError } from '../page/protocol.ts'
10
+ import { Door, DoorError } from './door.ts'
11
+ import { error, INVALID_JSON, jsonBody, operatorOnly, readJson, type ReportError } from './http.ts'
12
+ import { VERBS } from './verbs.ts'
13
+
14
+ export type CallResponse =
15
+ | { readonly ok: true; readonly value: unknown; readonly text: string }
16
+ | { readonly ok: false; readonly code: string; readonly message: string }
17
+
18
+ export function operatorRoutes(prefix: string, secret: string, door: Door, reportError: ReportError): Acryl {
19
+ return new Acryl()
20
+ .get(`${prefix}/verbs`, operatorOnly(secret), c => c.json({ verbs: VERBS }))
21
+ .post(`${prefix}/call`, operatorOnly(secret), jsonBody, async (c) => {
22
+ const body = await readJson(c)
23
+ if (body === INVALID_JSON || typeof body !== 'object' || body === null || typeof (body as { verb?: unknown }).verb !== 'string') {
24
+ return c.json({ ok: false, code: 'invalid', message: 'the body is { verb, args }' } satisfies CallResponse, 400)
25
+ }
26
+ const { verb, args } = body as { verb: string; args?: unknown }
27
+ try {
28
+ const answer = await door.run(verb, args, c.req.raw.signal)
29
+ return c.json({ ok: true, value: answer.value, text: answer.text } satisfies CallResponse)
30
+ } catch (cause) {
31
+ if (cause instanceof UiControlError || cause instanceof DoorError) {
32
+ return c.json({ ok: false, code: cause.code, message: cause.message } satisfies CallResponse, cause.code === 'invalid' || cause.code === 'unknown-verb' ? 400 : 200)
33
+ }
34
+ reportError(`run the door verb ${verb}`, cause)
35
+ return c.json(error('the verb could not be run'), 500)
36
+ }
37
+ })
38
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The page channel on the router: an event stream down to the page and POST up, so it needs no WebSocket upgrade on any adapter.
3
+ *
4
+ * `GET <prefix>/channel?window=<id>&focused=0|1` is the page's stream. The Host writes each call as `event: call` and a `ping` every
5
+ * heartbeat, so a page that hears nothing for a while knows the stream died and reconnects. `POST <prefix>/channel/answer` carries
6
+ * `{ window, message }` (a result, an error or a focus change). Both are for the app's own page only (same origin, loopback).
7
+ */
8
+
9
+ import { Acryl } from '../../../acryl-router/src/index.ts'
10
+ import { streamSSE } from '../../../acryl-router/src/helpers/index.ts'
11
+ import { loopbackOnly } from '../../../acryl-router/src/middleware/index.ts'
12
+ import { parseChannelToHost, WINDOW_ID } from '../page/protocol.ts'
13
+ import type { UiChannel } from './channel.ts'
14
+ import { error, INVALID_JSON, jsonBody, ownPage, readJson } from './http.ts'
15
+
16
+ /** How often the Host writes a `ping`; the page gives up on a stream that is silent for three of these. */
17
+ export const HEARTBEAT_MS = 15_000
18
+
19
+ export interface PageRoutesOptions {
20
+ /** Where the channel lives, e.g. `/api/acryl-door`. */
21
+ readonly prefix: string
22
+ readonly heartbeatMs?: number
23
+ /** The only hostnames the page may be served from (default: every loopback name), e.g. `['127.0.0.1']` for a Host that serves only that. */
24
+ readonly hostnames?: readonly string[]
25
+ }
26
+
27
+ export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions): Acryl {
28
+ const heartbeatMs = options.heartbeatMs ?? HEARTBEAT_MS
29
+ const page = options.hostnames === undefined ? ownPage : loopbackOnly({ callers: 'own-page', hostnames: options.hostnames })
30
+ return new Acryl()
31
+ .get(`${options.prefix}/channel`, page, (c) => {
32
+ const windowId = c.req.query('window')
33
+ if (windowId === undefined || !WINDOW_ID.test(windowId)) return c.json(error('a window id is required'), 400)
34
+ const focused = c.req.query('focused') === '1'
35
+ const signal = c.req.raw.signal
36
+ return streamSSE(c, async (stream) => {
37
+ let end = (): void => {}
38
+ const ended = new Promise<void>((resolve) => { end = resolve })
39
+ stream.onAbort(end)
40
+ signal.addEventListener('abort', end, { once: true })
41
+ const write = (event: string, data: string): void => {
42
+ stream.writeSSE({ event, data }).catch(end)
43
+ }
44
+ const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, close: end })
45
+ const beat = setInterval(() => { write('ping', '') }, heartbeatMs)
46
+ try {
47
+ write('ready', '')
48
+ await ended
49
+ } finally {
50
+ clearInterval(beat)
51
+ signal.removeEventListener('abort', end)
52
+ detach()
53
+ }
54
+ })
55
+ })
56
+ .post(`${options.prefix}/channel/answer`, page, jsonBody, async (c) => {
57
+ const body = await readJson(c)
58
+ if (body === INVALID_JSON || typeof body !== 'object' || body === null) return c.json(error('invalid request body'), 400)
59
+ const { window: windowId, message } = body as { window?: unknown; message?: unknown }
60
+ const parsed = parseChannelToHost(message)
61
+ if (typeof windowId !== 'string' || !WINDOW_ID.test(windowId) || parsed === null) return c.json(error('invalid message'), 400)
62
+ channel.receive(windowId, parsed)
63
+ return c.body(null, 204)
64
+ })
65
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The app folder's own command (spec 049 slice 10): `<app>/.acryl/bin/acryl` (and `acryl.cmd` on Windows) runs this door's operator
3
+ * client against this folder only (`--dir <app>`), with the Node that runs the app. An agent working in the folder calls it by that path,
4
+ * so it reaches the app running from this folder and never a user's own `acryl` on PATH or another app's door.
5
+ *
6
+ * Written each time the door starts, so a moved app or a new Node is picked up. It stays when the app stops: run then, it says that no
7
+ * app is running in the folder (exit 3). It carries no secret; the client reads `door.json` at each call.
8
+ */
9
+
10
+ import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { ensureStateDir } from './door-file.ts'
13
+
14
+ /** The folder of the command below the app folder's `.acryl/`. */
15
+ export const BIN_DIR = 'bin'
16
+
17
+ export interface ShimTarget {
18
+ /** The Node executable. */
19
+ readonly node: string
20
+ /** Node options the client needs, e.g. `--experimental-strip-types` when it runs from source on a Node that needs the flag. */
21
+ readonly nodeOptions: readonly string[]
22
+ /** The operator client's entry file. */
23
+ readonly client: string
24
+ /** True when `node` is Electron, which runs as Node only with `ELECTRON_RUN_AS_NODE` set. */
25
+ readonly electron: boolean
26
+ }
27
+
28
+ /** A word for `sh`: single-quoted, with each `'` closed, escaped and reopened. */
29
+ function shWord(value: string): string {
30
+ return `'${value.replaceAll('\'', '\'\\\'\'')}'`
31
+ }
32
+
33
+ /** A word for `cmd.exe`: double-quoted; `%` doubled so a batch file does not expand it. A path cannot hold `"`. */
34
+ function cmdWord(value: string): string {
35
+ return `"${value.replaceAll('%', '%%')}"`
36
+ }
37
+
38
+ export function posixShim(root: string, target: ShimTarget): string {
39
+ const words = [target.node, ...target.nodeOptions, target.client, '--dir', root].map(shWord).join(' ')
40
+ const env = target.electron ? 'export ELECTRON_RUN_AS_NODE=1\n' : ''
41
+ return `#!/bin/sh\n# This app's door (acryl-door): reaches only the app running from this folder. Rewritten each time the app starts.\n${env}exec ${words} "$@"\n`
42
+ }
43
+
44
+ export function windowsShim(root: string, target: ShimTarget): string {
45
+ const words = [target.node, ...target.nodeOptions, target.client, '--dir', root].map(cmdWord).join(' ')
46
+ const env = target.electron ? 'set ELECTRON_RUN_AS_NODE=1\r\n' : ''
47
+ return `@echo off\r\nsetlocal\r\nrem This app's door (acryl-door): reaches only the app running from this folder. Rewritten each time the app starts.\r\n${env}${words} %*\r\n`
48
+ }
49
+
50
+ function writeAtomically(file: string, content: string, mode: number): void {
51
+ const temporary = `${file}.${String(process.pid)}.tmp`
52
+ writeFileSync(temporary, content, { mode })
53
+ if (process.platform !== 'win32') chmodSync(temporary, mode)
54
+ renameSync(temporary, file)
55
+ }
56
+
57
+ /** Write `.acryl/bin/acryl` and `.acryl/bin/acryl.cmd` for the app at `root`. @returns the folder they are in. */
58
+ export function writeShims(root: string, target: ShimTarget): string {
59
+ const bin = join(ensureStateDir(root), BIN_DIR)
60
+ mkdirSync(bin, { recursive: true, mode: 0o700 })
61
+ if (process.platform !== 'win32') chmodSync(bin, 0o700)
62
+ writeAtomically(join(bin, 'acryl'), posixShim(root, target), 0o700)
63
+ writeAtomically(join(bin, 'acryl.cmd'), windowsShim(root, target), 0o600)
64
+ return bin
65
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The one verb list (spec 049 slice 13). Every face of the door reads it: the operator client (`acryl <verb>`, its `verbs` and `brief`),
3
+ * MCP (`tools/list`) and the built-in chat (the `tools` registry). A verb added here is in all three, or the comparison test fails.
4
+ */
5
+
6
+ import { MAX_SNAPSHOT_NODES, MAX_WAIT_MS } from '../page/protocol.ts'
7
+
8
+ /** Every verb, in the order `brief` lists them. */
9
+ export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call'] as const
10
+ export type Verb = (typeof VERBS)[number]
11
+
12
+ /** The JSON Schema of a verb's arguments, as MCP lists it. */
13
+ export interface ArgumentsSchema {
14
+ readonly type: 'object'
15
+ readonly properties: Readonly<Record<string, unknown>>
16
+ readonly required?: readonly string[]
17
+ readonly additionalProperties: boolean
18
+ }
19
+
20
+ export interface VerbSpec {
21
+ /** The operator client's form, as `brief` prints it. */
22
+ readonly usage: string
23
+ /** What the verb does, for a model choosing a tool. */
24
+ readonly description: string
25
+ readonly arguments: ArgumentsSchema
26
+ }
27
+
28
+ const none: ArgumentsSchema = { type: 'object', properties: {}, additionalProperties: false }
29
+ const ref = { type: 'string', description: 'a ref from the latest look, e.g. 3.12' }
30
+
31
+ export const VERB_SPECS: Readonly<Record<Verb, VerbSpec>> = {
32
+ 'brief': {
33
+ usage: 'brief: this summary',
34
+ description: 'What this app is, which files to read first, whether its page is open, and the verbs.',
35
+ arguments: none,
36
+ },
37
+ 'look': {
38
+ usage: 'look [--cursor <n>] [--max-nodes <n>]: the page as text, with refs',
39
+ description: 'The open page as text: its roles, names, values and visible text, each with a ref to act on. A long page comes in parts (cursor).',
40
+ arguments: {
41
+ type: 'object',
42
+ properties: {
43
+ cursor: { type: 'integer', minimum: 0, description: 'where the previous part ended' },
44
+ maxNodes: { type: 'integer', minimum: 1, maximum: MAX_SNAPSHOT_NODES },
45
+ },
46
+ additionalProperties: false,
47
+ },
48
+ },
49
+ 'act': {
50
+ usage: 'act click|type|select|press|scroll|wait ...: do one thing on the page (refs from the latest look)',
51
+ description: 'Do one thing on the page: click, type, select, press a key, scroll, or wait for text or a control. Look again before the next act.',
52
+ arguments: {
53
+ type: 'object',
54
+ properties: {
55
+ op: { type: 'string', enum: ['click', 'type', 'select', 'press', 'scroll', 'wait'] },
56
+ ref,
57
+ text: { type: 'string', description: 'type: what to type; wait: the text to wait for' },
58
+ submit: { type: 'boolean', description: 'type: press Enter after' },
59
+ clear: { type: 'boolean', description: 'type: false keeps what the field holds' },
60
+ option: { type: 'string', description: 'select: the option to choose' },
61
+ key: { type: 'string', description: 'press: a character or Enter, Escape, Tab, ArrowDown, ...' },
62
+ direction: { type: 'string', enum: ['up', 'down', 'left', 'right'] },
63
+ amount: { type: 'number', minimum: 1, maximum: 10_000, description: 'scroll: pixels' },
64
+ role: { type: 'string', description: 'wait: the role to wait for' },
65
+ name: { type: 'string', description: 'wait: its name' },
66
+ gone: { type: 'boolean', description: 'wait: until it is gone instead' },
67
+ timeoutMs: { type: 'integer', minimum: 0, maximum: MAX_WAIT_MS },
68
+ },
69
+ required: ['op'],
70
+ additionalProperties: false,
71
+ },
72
+ },
73
+ 'plugin-list': {
74
+ usage: 'plugin list: the plugins installed while the app runs',
75
+ description: 'The plugins installed into the running app.',
76
+ arguments: none,
77
+ },
78
+ 'plugin-add': {
79
+ usage: 'plugin add <folder>: check, install and start a plugin folder, live',
80
+ description: 'Check, install and start a plugin folder inside the app, without a restart.',
81
+ arguments: { type: 'object', properties: { folder: { type: 'string', description: 'a folder inside the app, e.g. plugins/pomodoro' } }, required: ['folder'], additionalProperties: false },
82
+ },
83
+ 'plugin-remove': {
84
+ usage: 'plugin remove <name>: stop and remove an installed plugin',
85
+ description: 'Stop and remove an installed plugin.',
86
+ arguments: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'], additionalProperties: false },
87
+ },
88
+ 'plugin-reload': {
89
+ usage: 'plugin reload: bring every installed plugin in line with its folder',
90
+ description: 'Bring every installed plugin in line with its folder (after editing one).',
91
+ arguments: none,
92
+ },
93
+ 'tool-list': {
94
+ usage: 'tool list: the app tools the door offers',
95
+ description: 'The app\'s own tools that the door offers.',
96
+ arguments: none,
97
+ },
98
+ 'tool-call': {
99
+ usage: 'tool call --tool <name> [--args <json>]: run one of them',
100
+ description: 'Run one of the app tools that tool-list names.',
101
+ arguments: { type: 'object', properties: { tool: { type: 'string' }, args: { description: 'the tool\'s input' } }, required: ['tool'], additionalProperties: false },
102
+ },
103
+ }
104
+
105
+ /** A verb's name in the `tools` registry the built-in chat reads: prefixed, so it never meets an app tool's name. */
106
+ export const chatToolName = (verb: Verb): string => `door_${verb.replaceAll('-', '_')}`
107
+
108
+ /** True for a name the door itself registers in the `tools` registry. */
109
+ export const isDoorToolName = (name: string): boolean => VERBS.some(verb => chatToolName(verb) === name)
@@ -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
+ }