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,142 @@
1
+ /**
2
+ * The Host's side of the page channel: the connected pages of the app, and calls that go to the current one (moved from
3
+ * `acryl-agent-control`, spec 041).
4
+ *
5
+ * The Host cannot reach into a page, so each page opens one event stream to the Host and answers calls by POST. With several pages open,
6
+ * the one the user focused last is the one the agent drives. A page that reconnects with the same window id replaces its old stream.
7
+ */
8
+
9
+ import {
10
+ UiControlError,
11
+ type ChannelToHost,
12
+ type ChannelToPage,
13
+ type UiRequest,
14
+ type UiResult,
15
+ } from '../page/protocol.ts'
16
+
17
+ /** The slice of a page's stream the channel needs, so tests can supply their own. */
18
+ export interface PageStream {
19
+ send(data: string): void
20
+ close(): void
21
+ }
22
+
23
+ interface Pending {
24
+ readonly connection: Connection
25
+ readonly resolve: (value: UiResult) => void
26
+ readonly reject: (cause: UiControlError) => void
27
+ readonly timer: NodeJS.Timeout
28
+ readonly cleanup: () => void
29
+ }
30
+
31
+ interface Connection {
32
+ readonly stream: PageStream
33
+ readonly windowId: string
34
+ focused: boolean
35
+ /** Order of last focus (or connection), so the most recent wins. */
36
+ lastActive: number
37
+ }
38
+
39
+ /** How long a call may take before it is given up (a wait may take up to 10 seconds itself). */
40
+ const DEFAULT_CALL_TIMEOUT_MS = 30_000
41
+
42
+ export class UiChannel {
43
+ private readonly connections = new Map<string, Connection>()
44
+ private readonly pending = new Map<number, Pending>()
45
+ private nextId = 1
46
+ private clock = 0
47
+ private closed = false
48
+
49
+ /**
50
+ * Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
51
+ * @returns the function to call when this stream ends.
52
+ */
53
+ attach(windowId: string, focused: boolean, stream: PageStream): () => void {
54
+ const previous = this.connections.get(windowId)
55
+ if (previous !== undefined) this.drop(previous, true)
56
+ const connection: Connection = { stream, windowId, focused, lastActive: ++this.clock }
57
+ this.connections.set(windowId, connection)
58
+ return () => { if (this.connections.get(windowId) === connection) this.drop(connection, false) }
59
+ }
60
+
61
+ /** A message a page sent by POST. Only the window that was asked may answer a call, so another page cannot forge a result. */
62
+ receive(windowId: string, message: ChannelToHost): void {
63
+ const connection = this.connections.get(windowId)
64
+ if (connection === undefined) return
65
+ if (message.t === 'focus') {
66
+ connection.focused = message.focused
67
+ if (message.focused) connection.lastActive = ++this.clock
68
+ return
69
+ }
70
+ const entry = this.pending.get(message.id)
71
+ if (entry === undefined || entry.connection !== connection) return
72
+ if (message.t === 'result') this.settle(message.id, message.value)
73
+ else this.settle(message.id, undefined, new UiControlError(message.code, message.message))
74
+ }
75
+
76
+ /** True when at least one page is connected. */
77
+ get connected(): boolean {
78
+ return this.connections.size > 0
79
+ }
80
+
81
+ /** The windows currently connected, most recently active first (for diagnostics and tests). */
82
+ windows(): readonly { windowId: string; focused: boolean }[] {
83
+ return this.byRecency().map(({ windowId, focused }) => ({ windowId, focused }))
84
+ }
85
+
86
+ /**
87
+ * Ask the current page to do something.
88
+ * @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
89
+ */
90
+ call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<UiResult> {
91
+ if (this.closed) return Promise.reject(new UiControlError('unloaded', 'the door was unloaded'))
92
+ const target = this.byRecency()[0]
93
+ if (target === undefined) return Promise.reject(new UiControlError('no-window', 'no page of the app is open to control'))
94
+ const signal = options.signal
95
+ if (signal?.aborted === true) return Promise.reject(new UiControlError('aborted', 'the call was cancelled'))
96
+ const id = this.nextId
97
+ this.nextId += 1
98
+ return new Promise<UiResult>((resolve, reject) => {
99
+ const timer = setTimeout(() => { this.settle(id, undefined, new UiControlError('timeout', 'the page did not answer in time')) }, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS)
100
+ const onAbort = (): void => { this.settle(id, undefined, new UiControlError('aborted', 'the call was cancelled')) }
101
+ signal?.addEventListener('abort', onAbort, { once: true })
102
+ this.pending.set(id, { connection: target, resolve, reject, timer, cleanup: () => { signal?.removeEventListener('abort', onAbort) } })
103
+ const frame: ChannelToPage = { t: 'call', id, request }
104
+ try {
105
+ target.stream.send(JSON.stringify(frame))
106
+ } catch {
107
+ this.settle(id, undefined, new UiControlError('disconnected', 'the page could not be reached'))
108
+ }
109
+ })
110
+ }
111
+
112
+ /** Close every page's stream and settle every pending call as `unloaded`. */
113
+ close(): void {
114
+ this.closed = true
115
+ for (const id of [...this.pending.keys()]) this.settle(id, undefined, new UiControlError('unloaded', 'the door was unloaded'))
116
+ for (const connection of [...this.connections.values()]) connection.stream.close()
117
+ this.connections.clear()
118
+ }
119
+
120
+ private byRecency(): Connection[] {
121
+ return [...this.connections.values()].sort((a, b) => b.lastActive - a.lastActive)
122
+ }
123
+
124
+ /** Forget a connection; its calls in flight settle as `disconnected` at once rather than waiting for their timeout. */
125
+ private drop(connection: Connection, closeStream: boolean): void {
126
+ this.connections.delete(connection.windowId)
127
+ for (const [id, entry] of [...this.pending]) {
128
+ if (entry.connection === connection) this.settle(id, undefined, new UiControlError('disconnected', 'the page went away before it answered'))
129
+ }
130
+ if (closeStream) connection.stream.close()
131
+ }
132
+
133
+ private settle(id: number, value: UiResult | undefined, failure?: UiControlError): void {
134
+ const entry = this.pending.get(id)
135
+ if (entry === undefined) return
136
+ this.pending.delete(id)
137
+ clearTimeout(entry.timer)
138
+ entry.cleanup()
139
+ if (failure !== undefined) entry.reject(failure)
140
+ else if (value !== undefined) entry.resolve(value)
141
+ }
142
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The door in the built-in chat: each verb registered in the app's `tools` registry (the one the chat's agent reads), as
3
+ * `door_<verb>`. A tool answers the verb's text, what an agent reads in a terminal; a failed verb throws with its code, as the operator
4
+ * client prints it.
5
+ */
6
+
7
+ import { UiControlError } from '../page/protocol.ts'
8
+ import { DoorError, type Door, type ToolsPort } from './door.ts'
9
+ import { chatToolName, VERB_SPECS, VERBS } from './verbs.ts'
10
+
11
+ /** Register every verb. @returns the function that removes them all; it also aborts the calls still running. */
12
+ export function registerChatTools(tools: ToolsPort, door: Door): () => void {
13
+ const running = new AbortController()
14
+ const removers: Array<() => void> = []
15
+ const removeAll = (): void => {
16
+ running.abort()
17
+ for (const remove of removers.splice(0).reverse()) remove()
18
+ }
19
+ try {
20
+ for (const verb of VERBS) {
21
+ removers.push(tools.register(chatToolName(verb), {
22
+ description: VERB_SPECS[verb].description,
23
+ run: async (input: unknown) => {
24
+ try {
25
+ return (await door.run(verb, input ?? {}, running.signal)).text
26
+ } catch (cause) {
27
+ if (cause instanceof DoorError || cause instanceof UiControlError) throw new Error(`${cause.code}: ${cause.message}`, { cause })
28
+ throw cause
29
+ }
30
+ },
31
+ }))
32
+ }
33
+ } catch (cause) {
34
+ // A name already taken: leave nothing half registered.
35
+ removeAll()
36
+ throw cause
37
+ }
38
+ return removeAll
39
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The door's connection file, `<app>/.acryl/door.json`: where the running app listens and this run's secret, for the operator client in
3
+ * the same folder. The secret is never on a command line or in an environment variable another process can read.
4
+ *
5
+ * Protection: on macOS and Linux the folder is `0700` and the file `0600`, so only the OS user who started the app can read it. On
6
+ * Windows file modes mean nothing; the file is protected by where it lives, the app folder in the user's profile, whose ACL gives access
7
+ * to that user, SYSTEM and Administrators only. The door does not widen it. The folder also carries its own `.gitignore` of `*`, so
8
+ * `git add -A` never stages the secret even in an app whose `.gitignore` lacks the `.acryl/` line.
9
+ */
10
+
11
+ import { randomBytes } from 'node:crypto'
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+
15
+ /** The folder of the door's state below the app folder. */
16
+ export const STATE_DIR = '.acryl'
17
+ export const DOOR_FILE = 'door.json'
18
+
19
+ export interface DoorFile {
20
+ readonly version: 1
21
+ /** The origin the operator reaches, on a loopback address. */
22
+ readonly url: string
23
+ readonly secret: string
24
+ /** The process that wrote it; a file whose process is gone is stale. */
25
+ readonly pid: number
26
+ }
27
+
28
+ export function stateDir(appRoot: string): string {
29
+ return join(appRoot, STATE_DIR)
30
+ }
31
+
32
+ export function doorFilePath(appRoot: string): string {
33
+ return join(stateDir(appRoot), DOOR_FILE)
34
+ }
35
+
36
+ /** A fresh secret for this run; a restart rotates it. */
37
+ export function newSecret(): string {
38
+ return randomBytes(24).toString('hex')
39
+ }
40
+
41
+ /** The state folder, private, with its own `.gitignore`. */
42
+ export function ensureStateDir(appRoot: string): string {
43
+ const dir = stateDir(appRoot)
44
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
45
+ if (process.platform !== 'win32') chmodSync(dir, 0o700)
46
+ const ignore = join(dir, '.gitignore')
47
+ if (!existsSync(ignore)) writeFileSync(ignore, '# The door\'s state: this run\'s secret and the audit. Never commit it.\n*\n')
48
+ return dir
49
+ }
50
+
51
+ /** The connection file as written, or undefined when there is none or it does not parse. */
52
+ export function readDoorFile(appRoot: string): DoorFile | undefined {
53
+ try {
54
+ const value = JSON.parse(readFileSync(doorFilePath(appRoot), 'utf8')) as Partial<DoorFile>
55
+ if (value.version === 1 && typeof value.url === 'string' && typeof value.secret === 'string' && typeof value.pid === 'number') return value as DoorFile
56
+ } catch {
57
+ // Missing or unreadable: there is no door to reach.
58
+ }
59
+ return undefined
60
+ }
61
+
62
+ /** True while the process is alive (a process of another user still counts: `EPERM`). */
63
+ function alive(pid: number): boolean {
64
+ try {
65
+ process.kill(pid, 0)
66
+ return true
67
+ } catch (cause) {
68
+ return (cause as NodeJS.ErrnoException).code === 'EPERM'
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Write this run's connection file, replacing a stale one.
74
+ * @throws when another live process already serves this folder's door: two apps from one folder would race for one file.
75
+ */
76
+ export function writeDoorFile(appRoot: string, url: string, secret: string): string {
77
+ const dir = ensureStateDir(appRoot)
78
+ const existing = readDoorFile(appRoot)
79
+ if (existing !== undefined && existing.pid !== process.pid && alive(existing.pid)) {
80
+ throw new Error(`acryl-door: another process (pid ${String(existing.pid)}) serves the door of ${appRoot}; stop it first`)
81
+ }
82
+ const file = doorFilePath(appRoot)
83
+ const temporary = join(dir, `${DOOR_FILE}.${String(process.pid)}.tmp`)
84
+ const content: DoorFile = { version: 1, url, secret, pid: process.pid }
85
+ writeFileSync(temporary, `${JSON.stringify(content)}\n`, { mode: 0o600 })
86
+ if (process.platform !== 'win32') chmodSync(temporary, 0o600)
87
+ renameSync(temporary, file)
88
+ return file
89
+ }
90
+
91
+ /** Remove the connection file, only when it is this run's: a newer run's file is left alone. */
92
+ export function removeDoorFile(appRoot: string, secret: string): void {
93
+ if (readDoorFile(appRoot)?.secret === secret) rmSync(doorFilePath(appRoot), { force: true })
94
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The door's verbs, one implementation for every face: `brief`, `look`, `act`, `plugin list|add|remove|reload` and `tool list|call`
3
+ * (`verbs.ts` lists them; the operator client, MCP and the built-in chat all call these). Each verb takes plain JSON arguments,
4
+ * returns a typed value and a text rendering, and fails with a {@link DoorError} or a {@link UiControlError} carrying a code.
5
+ */
6
+
7
+ import { existsSync } from 'node:fs'
8
+ import { isAbsolute, relative, resolve } from 'node:path'
9
+ import { parseUiRequest, UiControlError, type AuditEntry, type UiActionResult, type UiRequest, type UiResult, type UiSnapshot } from '../page/protocol.ts'
10
+ import { renderSnapshot } from '../page/snapshot-text.ts'
11
+ import type { AuditLog } from './audit.ts'
12
+ import type { UiChannel } from './channel.ts'
13
+ import { VERB_SPECS, VERBS } from './verbs.ts'
14
+
15
+ export type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed'
16
+
17
+ export class DoorError extends Error {
18
+ readonly code: DoorErrorCode
19
+
20
+ constructor(code: DoorErrorCode, message: string) {
21
+ super(message)
22
+ this.code = code
23
+ this.name = 'DoorError'
24
+ }
25
+ }
26
+
27
+ /** The slice of acrylblends' `extensions` service the plugin verbs use, declared here so the door does not depend on that plugin. */
28
+ export interface ExtensionsPort {
29
+ install(source: string): Promise<unknown>
30
+ remove(name: string): Promise<unknown>
31
+ reload(): Promise<unknown>
32
+ list(): readonly unknown[]
33
+ }
34
+
35
+ /** The slice of acrylblends' `tools` registry (the `agent` plugin's) the tool verbs use, and where the built-in chat finds the verbs. */
36
+ export interface ToolsPort {
37
+ /** @returns the function that removes the tool. @throws when the name is taken. */
38
+ register(name: string, tool: { readonly description: string; run(input: unknown): unknown }): () => void
39
+ list(): ReadonlyArray<{ readonly name: string; readonly description: string }>
40
+ call(name: string, input: unknown): Promise<unknown>
41
+ }
42
+
43
+ export interface DoorDeps {
44
+ readonly channel: UiChannel
45
+ readonly audit: AuditLog
46
+ /** The app's name and folder. */
47
+ readonly app: { readonly name: string; readonly root: string }
48
+ /** Read at each call: the services may come and go while the door runs. */
49
+ readonly extensions: () => ExtensionsPort | undefined
50
+ readonly tools: () => ToolsPort | undefined
51
+ /** The app tools the door offers; anything else is refused before it reaches the registry. */
52
+ readonly expose: readonly string[]
53
+ }
54
+
55
+ /** What one verb answered: the value for `--json` and programs, the text for an agent reading a terminal. */
56
+ export interface VerbAnswer {
57
+ readonly value: unknown
58
+ readonly text: string
59
+ }
60
+
61
+ /** The files an agent should read first, when the app has them. */
62
+ const SELF_KNOWLEDGE = ['AGENTS.md', 'CLAUDE.md', '.agents/skills', '.claude/skills', 'docs', 'plugins']
63
+
64
+ export class Door {
65
+ private readonly deps: DoorDeps
66
+
67
+ constructor(deps: DoorDeps) {
68
+ this.deps = deps
69
+ }
70
+
71
+ /** Run one verb. @throws DoorError or UiControlError. */
72
+ async run(verb: string, args: unknown, signal: AbortSignal): Promise<VerbAnswer> {
73
+ const input = (typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {}) as Record<string, unknown>
74
+ switch (verb) {
75
+ case 'brief': return this.brief()
76
+ case 'look': {
77
+ const snapshot = await this.look(input, signal)
78
+ return { value: snapshot, text: renderSnapshot(snapshot, cursor => `run look --cursor ${String(cursor)}`) }
79
+ }
80
+ case 'act': {
81
+ const result = await this.act(input, signal)
82
+ return { value: result, text: `done${result.target === undefined ? '' : `: ${result.target.role} "${result.target.name}"`}${result.detail === undefined ? '' : ` (${result.detail})`}; look again before the next act` }
83
+ }
84
+ case 'plugin-list': return this.plugin(extensions => extensions.list())
85
+ case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
86
+ case 'plugin-remove': {
87
+ const name = input.name
88
+ if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin remove needs a plugin name')
89
+ return this.plugin(extensions => extensions.remove(name))
90
+ }
91
+ case 'plugin-reload': return this.plugin(extensions => extensions.reload())
92
+ case 'tool-list': {
93
+ const offered = this.offeredTools()
94
+ return { value: offered, text: offered.length === 0 ? 'no app tools are offered through the door (tools.expose is empty)' : offered.map(tool => `${tool.name}: ${tool.description}`).join('\n') }
95
+ }
96
+ case 'tool-call': return this.callTool(input)
97
+ default: throw new DoorError('unknown-verb', `unknown verb ${JSON.stringify(verb)}; the verbs are ${VERBS.join(', ')}`)
98
+ }
99
+ }
100
+
101
+ brief(): VerbAnswer {
102
+ const { name, root } = this.deps.app
103
+ const read = SELF_KNOWLEDGE.filter(path => existsSync(resolve(root, path)))
104
+ const value = {
105
+ app: name,
106
+ root,
107
+ read,
108
+ page: this.deps.channel.connected ? 'open' : 'not open',
109
+ plugins: this.deps.extensions() === undefined ? 'unavailable' : 'available',
110
+ verbs: VERBS.map(verb => VERB_SPECS[verb].usage),
111
+ }
112
+ const text = [
113
+ `App ${name} in ${root}.`,
114
+ read.length === 0 ? 'No AGENTS.md or skill in the folder.' : `Read first: ${read.join(', ')}.`,
115
+ `Page: ${value.page}${value.page === 'not open' ? ' (open the app in a browser to look and act)' : ''}.`,
116
+ `Live plugins: ${value.plugins}.`,
117
+ 'Verbs:',
118
+ ...value.verbs.map(line => ` ${line}`),
119
+ ].join('\n')
120
+ return { value, text }
121
+ }
122
+
123
+ look(input: Record<string, unknown>, signal: AbortSignal): Promise<UiSnapshot> {
124
+ const request = this.parse({ ...input, op: 'snapshot' })
125
+ return this.callPage('look', request, signal) as Promise<UiSnapshot>
126
+ }
127
+
128
+ act(input: Record<string, unknown>, signal: AbortSignal): Promise<UiActionResult> {
129
+ if (input.op === 'snapshot') throw new DoorError('invalid', 'use look to see the page')
130
+ return this.callPage(`act ${String(input.op)}`, this.parse(input), signal) as Promise<UiActionResult>
131
+ }
132
+
133
+ private parse(input: Record<string, unknown>): UiRequest {
134
+ try {
135
+ return parseUiRequest(input)
136
+ } catch (cause) {
137
+ throw new DoorError('invalid', cause instanceof Error ? cause.message : 'invalid request')
138
+ }
139
+ }
140
+
141
+ /** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
142
+ private async callPage(verb: string, request: UiRequest, signal: AbortSignal): Promise<UiResult> {
143
+ const approval: AuditEntry['approval'] = 'token'
144
+ try {
145
+ const value = await this.deps.channel.call(request, { signal, ...(request.op === 'wait' ? { timeoutMs: (request.timeoutMs ?? 3000) + 5000 } : {}) })
146
+ const target = 'target' in value ? value.target : undefined
147
+ this.deps.audit.record({ at: new Date().toISOString(), tool: verb, ...(target === undefined ? {} : { target }), outcome: 'ok', approval })
148
+ return value
149
+ } catch (cause) {
150
+ const code = cause instanceof UiControlError ? cause.code : 'failed'
151
+ const refused = cause instanceof UiControlError && ['protected', 'sensitive', 'killed', 'invalid', 'stale-ref', 'unknown-ref', 'not-actionable'].includes(cause.code)
152
+ this.deps.audit.record({ at: new Date().toISOString(), tool: verb, outcome: refused ? 'refused' : 'failed', detail: code, approval })
153
+ throw cause
154
+ }
155
+ }
156
+
157
+ private async plugin(run: (extensions: ExtensionsPort) => unknown): Promise<VerbAnswer> {
158
+ const extensions = this.deps.extensions()
159
+ if (extensions === undefined) throw new DoorError('unavailable', 'this app has no extensions plugin, so plugins cannot be installed while it runs')
160
+ const value = await run(extensions)
161
+ return { value, text: JSON.stringify(value, null, 2) }
162
+ }
163
+
164
+ /** A folder named by an agent, resolved from the app folder; it must be inside the app. */
165
+ private insideApp(folder: unknown): string {
166
+ if (typeof folder !== 'string' || folder === '') throw new DoorError('invalid', 'plugin add needs a folder, e.g. plugins/pomodoro')
167
+ const root = this.deps.app.root
168
+ const full = resolve(root, folder)
169
+ const path = relative(root, full)
170
+ if (path === '' || path.startsWith('..') || isAbsolute(path)) throw new DoorError('outside-app', `${folder} is not a folder inside the app (${root})`)
171
+ return full
172
+ }
173
+
174
+ private offeredTools(): Array<{ name: string; description: string }> {
175
+ const tools = this.deps.tools()
176
+ if (tools === undefined) return []
177
+ const allowed = new Set(this.deps.expose)
178
+ return tools.list().filter(tool => allowed.has(tool.name)).map(({ name, description }) => ({ name, description }))
179
+ }
180
+
181
+ private async callTool(input: Record<string, unknown>): Promise<VerbAnswer> {
182
+ const name = input.tool
183
+ if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'tool call needs --tool <name>')
184
+ if (!this.deps.expose.includes(name)) throw new DoorError('not-exposed', `${name} is not offered through the door`)
185
+ const tools = this.deps.tools()
186
+ if (tools === undefined || !tools.list().some(tool => tool.name === name)) throw new DoorError('unavailable', `${name} is not available right now`)
187
+ let value: unknown
188
+ try {
189
+ value = await tools.call(name, input.args ?? {})
190
+ } catch (cause) {
191
+ throw new DoorError('failed', cause instanceof Error ? cause.message : String(cause))
192
+ }
193
+ return { value, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }
194
+ }
195
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * What every door route shares (moved from `acryl-agent-control`, spec 041). Two kinds of caller reach it, each with its own guard: the
3
+ * app's own page (same origin, on a loopback address), and an operator on this machine (`.acryl/bin/acryl`, an agent's shell) presenting
4
+ * this run's secret.
5
+ */
6
+
7
+ import { timingSafeEqual } from 'node:crypto'
8
+ import { Acryl, type Context, type MiddlewareHandler } from '../../../acryl-router/src/index.ts'
9
+ import { bodyLimit, loopbackAuthority, loopbackOnly, methodNotAllowed } from '../../../acryl-router/src/middleware/index.ts'
10
+
11
+ /** Every door route lives below this path. */
12
+ export const DOOR_PREFIX = '/api/acryl-door'
13
+
14
+ /** The cap on a JSON request body. */
15
+ export const MAX_BODY_BYTES = 64 * 1024
16
+
17
+ export type ReportError = (operation: string, cause: unknown) => void
18
+
19
+ /** The JSON body of a refusal. */
20
+ export function error(message: string): { readonly error: string } {
21
+ return { error: message }
22
+ }
23
+
24
+ /** The app's own page, on a loopback address, and nothing else: another origin, another port and a script on the machine are refused. */
25
+ export const ownPage: MiddlewareHandler = loopbackOnly({ callers: 'own-page' })
26
+
27
+ /** A constant-time comparison of a `Bearer` header with the secret. */
28
+ export function tokenMatches(header: string | undefined, secret: string): boolean {
29
+ const presented = header?.startsWith('Bearer ') === true ? header.slice('Bearer '.length) : ''
30
+ const a = Buffer.from(presented)
31
+ const b = Buffer.from(secret)
32
+ return a.length === b.length && timingSafeEqual(a, b)
33
+ }
34
+
35
+ /**
36
+ * Only an operator on this machine: a loopback peer addressing this server's own port (`loopbackAuthority`), presenting this run's
37
+ * secret. Anything else is 403 `forbidden`, which the client reads as "refused: the app restarted and the secret rotated?".
38
+ */
39
+ export function operatorOnly(secret: string): MiddlewareHandler {
40
+ return async (c, next) => {
41
+ if (loopbackAuthority(c) === undefined || !tokenMatches(c.req.header('authorization'), secret)) return c.json(error('forbidden'), 403)
42
+ await next()
43
+ }
44
+ }
45
+
46
+ /** The headers every private answer carries, set after the handler so a refusal carries them too. */
47
+ const privateHeaders: MiddlewareHandler = async (c, next) => {
48
+ await next()
49
+ c.header('cache-control', 'no-store')
50
+ c.header('x-content-type-options', 'nosniff')
51
+ }
52
+
53
+ /**
54
+ * The door's routes as one app: each part's routes, every answer with the private headers, and a known path asked with another method
55
+ * answered 405 with its `Allow` before any guard runs. The middleware is scoped to the prefix, so it never wraps another plugin's routes.
56
+ */
57
+ export function privateApi(prefix: string, ...routes: readonly Acryl[]): Acryl {
58
+ const app = new Acryl()
59
+ app.use(`${prefix}/*`, privateHeaders, methodNotAllowed({
60
+ app,
61
+ onMethodNotAllowed: (c, allowed) => c.json(error('method not allowed'), 405, { allow: allowed.join(', ') }),
62
+ }))
63
+ for (const part of routes) app.route('/', part)
64
+ return app
65
+ }
66
+
67
+ /** Refuse a body over {@link MAX_BODY_BYTES} with 413 before the handler reads it. */
68
+ export const jsonBody: MiddlewareHandler = bodyLimit({ maxSize: MAX_BODY_BYTES, onError: c => c.json(error('request body is too large'), 413) })
69
+
70
+ /** Returned by {@link readJson} for a body that is not JSON. */
71
+ export const INVALID_JSON = Symbol('invalid JSON')
72
+
73
+ /** The parsed body, or {@link INVALID_JSON}. */
74
+ export async function readJson(c: Context): Promise<unknown> {
75
+ try {
76
+ return await c.req.json<unknown>()
77
+ } catch {
78
+ return INVALID_JSON
79
+ }
80
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The door over MCP: `POST <prefix>/mcp`, streamable HTTP in its request/response form (one JSON-RPC message or a batch in, its JSON
3
+ * answer out, 202 for notifications only; no event stream is offered). Each verb is one tool, named as the verb, with the arguments the
4
+ * operator client sends. The same guard as the operator's routes: loopback, this run's secret.
5
+ */
6
+
7
+ import { Acryl } from '../../../acryl-router/src/index.ts'
8
+ import { UiControlError } from '../page/protocol.ts'
9
+ import type { Door } from './door.ts'
10
+ import { DoorError } from './door.ts'
11
+ import { INVALID_JSON, jsonBody, operatorOnly, readJson, type ReportError } from './http.ts'
12
+ import { VERB_SPECS, VERBS, type Verb } from './verbs.ts'
13
+
14
+ const SUPPORTED_PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'] as const
15
+ const SERVER_INFO = { name: 'acryl-door', version: '1' } as const
16
+
17
+ interface JsonRpcRequest { readonly jsonrpc: '2.0'; readonly id?: string | number | null; readonly method: string; readonly params?: unknown }
18
+
19
+ function isRequest(value: unknown): value is JsonRpcRequest {
20
+ return typeof value === 'object' && value !== null && (value as { jsonrpc?: unknown }).jsonrpc === '2.0' && typeof (value as { method?: unknown }).method === 'string'
21
+ }
22
+
23
+ const rpcError = (id: JsonRpcRequest['id'], code: number, message: string) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } })
24
+ const result = (id: JsonRpcRequest['id'], value: unknown) => ({ jsonrpc: '2.0', id: id ?? null, result: value })
25
+
26
+ /** The verbs as MCP tools. */
27
+ export function mcpTools(): Array<{ name: Verb; description: string; inputSchema: unknown }> {
28
+ return VERBS.map(verb => ({ name: verb, description: VERB_SPECS[verb].description, inputSchema: VERB_SPECS[verb].arguments }))
29
+ }
30
+
31
+ const isVerb = (name: unknown): name is Verb => typeof name === 'string' && (VERBS as readonly string[]).includes(name)
32
+
33
+ /** One JSON-RPC message; `undefined` for a notification (no answer owed). */
34
+ async function answer(message: JsonRpcRequest, door: Door, signal: AbortSignal): Promise<Record<string, unknown> | undefined> {
35
+ const isNotification = message.id === undefined
36
+ switch (message.method) {
37
+ case 'initialize': {
38
+ const asked = (message.params as { protocolVersion?: unknown } | undefined)?.protocolVersion
39
+ const protocolVersion = (SUPPORTED_PROTOCOLS as readonly unknown[]).includes(asked) ? asked : SUPPORTED_PROTOCOLS[0]
40
+ return result(message.id, { protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo: SERVER_INFO })
41
+ }
42
+ case 'ping':
43
+ return isNotification ? undefined : result(message.id, {})
44
+ case 'tools/list':
45
+ return result(message.id, { tools: mcpTools() })
46
+ case 'tools/call': {
47
+ // MCP allows extra fields on params (`_meta`, a progress token); only `name` and `arguments` mean anything here.
48
+ const params = typeof message.params === 'object' && message.params !== null ? message.params as Record<string, unknown> : {}
49
+ if (!isVerb(params.name)) return rpcError(message.id, -32602, `unknown tool ${JSON.stringify(params.name)}; the tools are ${VERBS.join(', ')}`)
50
+ try {
51
+ const verbAnswer = await door.run(params.name, params.arguments ?? {}, signal)
52
+ return result(message.id, { content: [{ type: 'text', text: verbAnswer.text }], structuredContent: { value: verbAnswer.value }, isError: false })
53
+ } catch (cause) {
54
+ // A verb that failed is a tool result the model reads, with the same code the operator client prints.
55
+ if (cause instanceof DoorError || cause instanceof UiControlError) return result(message.id, { content: [{ type: 'text', text: `${cause.code}: ${cause.message}` }], isError: true })
56
+ throw cause
57
+ }
58
+ }
59
+ default:
60
+ return isNotification ? undefined : rpcError(message.id, -32601, `method not found: ${message.method}`)
61
+ }
62
+ }
63
+
64
+ export function mcpRoutes(prefix: string, secret: string, door: Door, reportError: ReportError): Acryl {
65
+ return new Acryl().post(`${prefix}/mcp`, operatorOnly(secret), jsonBody, async (c) => {
66
+ const body = await readJson(c)
67
+ if (body === INVALID_JSON) return c.json(rpcError(null, -32700, 'parse error'), 400)
68
+ try {
69
+ const messages = Array.isArray(body) ? body : [body]
70
+ const answers: Record<string, unknown>[] = []
71
+ for (const message of messages) {
72
+ if (!isRequest(message)) { answers.push(rpcError(null, -32600, 'invalid request')); continue }
73
+ const response = await answer(message, door, c.req.raw.signal)
74
+ if (response !== undefined) answers.push(response)
75
+ }
76
+ if (answers.length === 0) return c.body(null, 202)
77
+ return c.json(Array.isArray(body) ? answers : (answers[0] as object))
78
+ } catch (cause) {
79
+ reportError('answer an MCP request', cause)
80
+ return c.json(rpcError(null, -32603, 'internal error'), 500)
81
+ }
82
+ })
83
+ }