acrylblends 0.7.8 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/index.d.ts +13 -5
  2. package/dist/index.js +72 -13
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +2 -2
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/getting-started/basic.md +1 -2
  7. package/docs/framework/getting-started/desktop.md +1 -1
  8. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  9. package/docs/framework/guides/create-acrylblends.md +5 -4
  10. package/docs/framework/guides/data.md +2 -1
  11. package/docs/framework/upstream.json +3 -3
  12. package/lib/node-server.js +1 -0
  13. package/package.json +1 -1
  14. package/plugins/acryl-db/plugin.json +6 -1
  15. package/plugins/acryl-db/src/conformance.ts +224 -0
  16. package/plugins/acryl-db/upstream.json +1 -1
  17. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  18. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  19. package/plugins/acryl-door/LICENSE +47 -0
  20. package/plugins/acryl-door/plugin.json +14 -0
  21. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  22. package/plugins/acryl-door/src/host/api.ts +9 -0
  23. package/plugins/acryl-door/src/host/audit.ts +64 -0
  24. package/plugins/acryl-door/src/host/channel.ts +142 -0
  25. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  26. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  27. package/plugins/acryl-door/src/host/door.ts +195 -0
  28. package/plugins/acryl-door/src/host/http.ts +80 -0
  29. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  30. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  31. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  32. package/plugins/acryl-door/src/host/shim.ts +65 -0
  33. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  34. package/plugins/acryl-door/src/index.ts +133 -0
  35. package/plugins/acryl-door/src/page/api.ts +9 -0
  36. package/plugins/acryl-door/src/page/channel.ts +135 -0
  37. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  38. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  39. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  40. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  41. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  42. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  43. package/plugins/acryl-door/src/page/index.ts +40 -0
  44. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  45. package/plugins/acryl-door/src/page/main.ts +7 -0
  46. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  47. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  48. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  49. package/plugins/acryl-door/upstream.json +28 -0
  50. package/plugins/acryl-inspect/upstream.json +1 -1
  51. package/plugins/acryl-kv/plugin.json +6 -1
  52. package/plugins/acryl-kv/src/conformance.ts +226 -0
  53. package/plugins/acryl-kv/upstream.json +1 -1
  54. package/plugins/acryl-kv-db/plugin.json +4 -1
  55. package/plugins/acryl-kv-db/upstream.json +1 -1
  56. package/plugins/acryl-kv-json/plugin.json +4 -1
  57. package/plugins/acryl-kv-json/upstream.json +1 -1
  58. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  59. package/plugins/acryl-node-server/upstream.json +2 -2
  60. package/plugins/acryl-router/src/service.ts +3 -0
  61. package/plugins/acryl-router/upstream.json +2 -2
  62. package/plugins/acryl-router-http/upstream.json +1 -1
  63. package/plugins/acryl-state/upstream.json +1 -1
  64. package/plugins/agent-panel/client/main.ts +159 -0
  65. package/plugins/agent-panel/index.ts +232 -0
  66. package/plugins/agent-panel/plugin.json +21 -0
  67. package/plugins/desktop/upstream.json +1 -1
  68. package/plugins/desktop-web/upstream.json +1 -1
  69. package/plugins/extensions/resolve.ts +16 -2
  70. package/plugins/tsconfig.json +6 -0
  71. package/shared/DEFAULTS.md +11 -6
  72. package/shared/SKILL.md +1 -1
  73. package/templates/agent/blend.yaml +3 -0
  74. package/templates/agent/src/app.ts +18 -2
  75. package/templates/agent/src/routes.ts +14 -1
  76. package/templates/agent/template.json +2 -1
  77. package/templates/agent/test/agent.test.ts +10 -4
  78. package/templates/agent/test/app.test.ts +29 -8
  79. package/templates/agent/test/builder.test.ts +34 -23
  80. package/templates/agent/test/model.test.ts +24 -19
  81. package/templates/blank/src/app.ts +25 -5
  82. package/templates/blank/test/app.test.ts +28 -9
  83. package/templates/chat/AGENTS.md +16 -0
  84. package/templates/chat/blend.yaml +32 -0
  85. package/templates/chat/gitignore +6 -0
  86. package/templates/chat/migrations/001-chat.sql +12 -0
  87. package/templates/chat/package.json +33 -0
  88. package/templates/chat/src/app.ts +107 -0
  89. package/templates/chat/src/chat-app.ts +101 -0
  90. package/templates/chat/src/client/chat.tsx +115 -0
  91. package/templates/chat/src/client/tsconfig.json +26 -0
  92. package/templates/chat/src/db.ts +43 -0
  93. package/templates/chat/src/door.ts +7 -0
  94. package/templates/chat/src/llm.ts +42 -0
  95. package/templates/chat/src/main.ts +28 -0
  96. package/templates/chat/src/page.tsx +40 -0
  97. package/templates/chat/src/routes.ts +15 -0
  98. package/templates/chat/src/runtime.ts +17 -0
  99. package/templates/chat/template.json +12 -0
  100. package/templates/chat/test/chat.test.ts +198 -0
  101. package/templates/chat/test/server.test.ts +78 -0
  102. package/templates/chat/tsconfig.json +20 -0
  103. package/templates/nodejs/AGENTS.md +9 -0
  104. package/templates/nodejs/blend.yaml +29 -0
  105. package/templates/nodejs/gitignore +3 -0
  106. package/templates/nodejs/package.json +30 -0
  107. package/templates/nodejs/src/app.ts +95 -0
  108. package/templates/nodejs/src/client/counter.tsx +12 -0
  109. package/templates/nodejs/src/client/tsconfig.json +26 -0
  110. package/templates/nodejs/src/door.ts +7 -0
  111. package/templates/nodejs/src/main.ts +25 -0
  112. package/templates/nodejs/src/page.tsx +29 -0
  113. package/templates/nodejs/src/routes.ts +40 -0
  114. package/templates/nodejs/template.json +11 -0
  115. package/templates/nodejs/test/app.test.ts +78 -0
  116. package/templates/nodejs/test/server.test.ts +83 -0
  117. package/templates/nodejs/tsconfig.json +20 -0
  118. package/templates/notes/AGENTS.md +26 -0
  119. package/templates/notes/blend.yaml +32 -0
  120. package/templates/notes/gitignore +6 -0
  121. package/templates/notes/migrations/001-notes.sql +18 -0
  122. package/templates/notes/package.json +33 -0
  123. package/templates/notes/src/app.ts +107 -0
  124. package/templates/notes/src/auth.ts +57 -0
  125. package/templates/notes/src/client/counter.tsx +21 -0
  126. package/templates/notes/src/client/tsconfig.json +26 -0
  127. package/templates/notes/src/db.ts +50 -0
  128. package/templates/notes/src/door.ts +7 -0
  129. package/templates/notes/src/main.ts +28 -0
  130. package/templates/notes/src/notes-app.ts +205 -0
  131. package/templates/notes/src/routes.ts +14 -0
  132. package/templates/notes/src/runtime.ts +17 -0
  133. package/templates/notes/src/schema.ts +38 -0
  134. package/templates/notes/src/views.tsx +136 -0
  135. package/templates/notes/template.json +12 -0
  136. package/templates/notes/test/notes.test.ts +280 -0
  137. package/templates/notes/test/server.test.ts +78 -0
  138. package/templates/notes/tsconfig.json +20 -0
  139. package/templates/todo/src/app.ts +27 -7
  140. package/templates/todo/test/todo.test.ts +47 -22
  141. package/templates/todo/upstream.json +1 -1
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-router-http",
5
- "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
5
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-state",
5
- "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
5
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -0,0 +1,159 @@
1
+ // The panel in the page: a button at the corner opens a side panel (a shadow root, so the app's CSS cannot touch it and it cannot touch the app's). It asks the app's
2
+ // agent through POST /api/agent-panel/ask, shows the answer as it arrives, and reloads the page after a run that installed or removed a plugin.
3
+ const PREFIX = '/api/agent-panel'
4
+
5
+ interface Session {
6
+ token: string
7
+ drivers: string[]
8
+ }
9
+ interface PanelEvent {
10
+ event: string
11
+ data: Record<string, unknown>
12
+ }
13
+
14
+ /** The events of a server-sent stream, as they arrive. */
15
+ async function* events(response: Response): AsyncGenerator<PanelEvent> {
16
+ const reader = response.body!.getReader()
17
+ const decoder = new TextDecoder()
18
+ let buffer = ''
19
+ for (;;) {
20
+ const { value, done } = await reader.read()
21
+ if (done) return
22
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/gu, '\n')
23
+ for (let end = buffer.indexOf('\n\n'); end !== -1; end = buffer.indexOf('\n\n')) {
24
+ const block = buffer.slice(0, end)
25
+ buffer = buffer.slice(end + 2)
26
+ let event = 'message'
27
+ let data = ''
28
+ for (const line of block.split('\n')) {
29
+ if (line.startsWith('event:')) event = line.slice(6).trim()
30
+ else if (line.startsWith('data:')) data += line.slice(5).trim()
31
+ }
32
+ if (data !== '') yield { event, data: JSON.parse(data) as Record<string, unknown> }
33
+ }
34
+ }
35
+ }
36
+
37
+ const css = `
38
+ :host { all: initial }
39
+ * { box-sizing: border-box; font: 14px system-ui, sans-serif }
40
+ button.open { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000; border: 0; border-radius: 24px; padding: 10px 16px; background: #1f2937; color: #fff; cursor: pointer; box-shadow: 0 2px 8px #0004 }
41
+ section { position: fixed; top: 0; right: 0; bottom: 0; width: min(380px, 100vw); z-index: 2147483001; display: none; flex-direction: column; background: #fff; color: #111827; border-left: 1px solid #d1d5db; box-shadow: -2px 0 12px #0003 }
42
+ section.shown { display: flex }
43
+ header { display: flex; gap: 8px; align-items: center; padding: 8px 12px; border-bottom: 1px solid #e5e7eb }
44
+ header strong { flex: 1 }
45
+ #log { flex: 1; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px }
46
+ .msg { white-space: pre-wrap; padding: 8px 10px; border-radius: 8px; background: #f3f4f6 }
47
+ .me { background: #dbeafe; align-self: flex-end }
48
+ .tool { font: 12px ui-monospace, monospace; color: #4b5563; background: none; padding: 0 }
49
+ .err { background: #fee2e2 }
50
+ form { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-top: 1px solid #e5e7eb }
51
+ textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border: 1px solid #d1d5db; border-radius: 6px }
52
+ .row { display: flex; gap: 8px; align-items: center }
53
+ .row button, header button { padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer }
54
+ .row button.send { background: #1f2937; color: #fff; border-color: #1f2937 }
55
+ `
56
+
57
+ async function start(): Promise<void> {
58
+ const reply = await fetch(`${PREFIX}/session`)
59
+ if (!reply.ok) return
60
+ const session = (await reply.json()) as Session
61
+
62
+ const host = document.createElement('div')
63
+ host.id = 'agent-panel'
64
+ const shadow = host.attachShadow({ mode: 'open' })
65
+ shadow.innerHTML = `<style>${css}</style>
66
+ <button class="open" type="button">Ask the agent</button>
67
+ <section aria-label="Agent panel">
68
+ <header><strong>Agent</strong><select aria-label="Agent">${session.drivers.map((d) => `<option>${d}</option>`).join('')}</select><button class="close" type="button" aria-label="Close">×</button></header>
69
+ <div id="log" role="log"></div>
70
+ <form><textarea aria-label="Message" placeholder="What should the app do?"></textarea>
71
+ <div class="row"><button class="send" type="submit">Send</button><button class="stop" type="button" disabled>Stop</button></div></form>
72
+ </section>`
73
+ const $ = <T extends Element>(selector: string): T => shadow.querySelector<T>(selector)!
74
+ const panel = $<HTMLElement>('section')
75
+ const log = $<HTMLElement>('#log')
76
+ const input = $<HTMLTextAreaElement>('textarea')
77
+ const send = $<HTMLButtonElement>('.send')
78
+ const stop = $<HTMLButtonElement>('.stop')
79
+ const driver = $<HTMLSelectElement>('select')
80
+ const show = (open: boolean): void => {
81
+ panel.classList.toggle('shown', open)
82
+ if (open) input.focus()
83
+ }
84
+ $('button.open').addEventListener('click', () => show(true))
85
+ $('button.close').addEventListener('click', () => show(false))
86
+ const line = (text: string, kind = ''): HTMLElement => {
87
+ const el = document.createElement('div')
88
+ el.className = `msg ${kind}`
89
+ el.textContent = text
90
+ log.append(el)
91
+ log.scrollTop = log.scrollHeight
92
+ return el
93
+ }
94
+
95
+ let controller: AbortController | undefined
96
+ stop.addEventListener('click', () => controller?.abort())
97
+ shadow.querySelector('form')!.addEventListener('submit', async (submitted) => {
98
+ submitted.preventDefault()
99
+ const message = input.value.trim()
100
+ if (message === '' || controller) return
101
+ input.value = ''
102
+ line(message, 'me')
103
+ controller = new AbortController()
104
+ send.disabled = true
105
+ stop.disabled = false
106
+ let changed = false
107
+ let answer: HTMLElement | undefined
108
+ try {
109
+ const response = await fetch(`${PREFIX}/ask`, {
110
+ method: 'POST',
111
+ headers: { 'content-type': 'application/json', 'x-agent-panel-token': session.token },
112
+ body: JSON.stringify({ message, driver: driver.value }),
113
+ signal: controller.signal,
114
+ })
115
+ if (!response.ok) {
116
+ line(
117
+ ((await response.json().catch(() => ({}))) as { error?: string }).error ??
118
+ `The panel answered ${response.status}.`,
119
+ 'err',
120
+ )
121
+ return
122
+ }
123
+ for await (const { event, data } of events(response)) {
124
+ if (event === 'text') {
125
+ answer ??= line('')
126
+ answer.textContent += String(data.text ?? '')
127
+ } else if (event === 'tool') {
128
+ line(`${String(data.tool)}${data.error ? ` failed: ${String(data.error)}` : ''}`, 'tool')
129
+ } else if (event === 'change') {
130
+ changed = true
131
+ line(`plugin ${String(data.extension)}: ${String(data.action)}`, 'tool')
132
+ } else if (event === 'error') {
133
+ line(String(data.message ?? 'The run failed.'), 'err')
134
+ } else if (event === 'done') {
135
+ changed = changed || data.changed === true
136
+ }
137
+ }
138
+ } catch (error) {
139
+ line(
140
+ controller?.signal.aborted
141
+ ? 'Stopped.'
142
+ : `The request failed: ${error instanceof Error ? error.message : String(error)}`,
143
+ 'err',
144
+ )
145
+ } finally {
146
+ controller = undefined
147
+ send.disabled = false
148
+ stop.disabled = true
149
+ }
150
+ // A plugin was installed or removed: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
151
+ if (changed && input.value.trim() === '') {
152
+ line('Reloading the page to show it.', 'tool')
153
+ setTimeout(() => location.reload(), 900)
154
+ }
155
+ })
156
+ document.body.append(host)
157
+ }
158
+
159
+ void start()
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The built-in agent (spec 049 slice 14): a chat panel inside the running app. The person says what they want; the app's own agent (`driver: 'agent'`) or a coding
3
+ * agent started in the app folder (`'claude'`, `'codex'`, `'pi'`) builds it through the door, and when a plugin was installed or removed the page reloads, so the new feature
4
+ * shows with no restart.
5
+ *
6
+ * - A middleware appends one script tag (`/api/agent-panel/panel.js`) to every HTML response of the routes mounted after it, so no page is edited.
7
+ * - `GET /api/agent-panel/session` gives the page the drivers it may use and a token; `POST /api/agent-panel/ask` runs one message and answers server-sent events:
8
+ * `text`, `tool`, `change`, `done`, `error`.
9
+ * - Only the app's own page may ask (a same-origin request carrying the token), one run at a time, and every ask is written to `.acryl/audit/agent-panel.jsonl`.
10
+ * - Development only: the plugin does nothing with `NODE_ENV=production` or `ACRYL_DOOR=off`.
11
+ */
12
+ import { spawn, spawnSync } from 'node:child_process'
13
+ import { randomBytes } from 'node:crypto'
14
+ import { appendFileSync, mkdirSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import { clientModules } from '../acryl-node-server/src/client-modules.ts'
18
+ import { streamSSE } from '../acryl-router/src/helpers/index.ts'
19
+ import { Acryl } from '../acryl-router/src/index.ts'
20
+ import type {} from '../agent/index.ts'
21
+ import type {} from '../extensions/index.ts'
22
+
23
+ export const name = 'agent-panel'
24
+ export const inject = ['router', 'agent', 'extensions', 'appInstance']
25
+
26
+ const PREFIX = '/api/agent-panel'
27
+ const CLIENT_DIR = join(import.meta.dirname, 'client')
28
+
29
+ export type DriverName = 'agent' | 'claude' | 'codex' | 'pi'
30
+
31
+ export interface PanelConfig {
32
+ /** Driver programs to offer besides the app's own agent; default: the ones found on the machine. */
33
+ drivers?: readonly Exclude<DriverName, 'agent'>[]
34
+ }
35
+
36
+ /** The command line of each coding agent started headless in the app folder (the same ones the product test uses). */
37
+ function command(
38
+ driver: Exclude<DriverName, 'agent'>,
39
+ message: string,
40
+ root: string,
41
+ ): { file: string; args: string[] } {
42
+ switch (driver) {
43
+ case 'claude':
44
+ return {
45
+ file: 'claude',
46
+ args: [
47
+ '-p',
48
+ message,
49
+ '--permission-mode',
50
+ 'acceptEdits',
51
+ '--allowedTools',
52
+ 'Read,Edit,Write,Glob,Grep,Bash(npm *),Bash(npx *),Bash(node *),Bash(.acryl/bin/acryl *),Bash(curl *)',
53
+ ],
54
+ }
55
+ case 'codex':
56
+ return {
57
+ file: 'codex',
58
+ args: [
59
+ 'exec',
60
+ '--skip-git-repo-check',
61
+ '-s',
62
+ 'workspace-write',
63
+ '-c',
64
+ 'sandbox_workspace_write.network_access=true',
65
+ '-C',
66
+ root,
67
+ message,
68
+ ],
69
+ }
70
+ case 'pi':
71
+ return {
72
+ file: 'pi',
73
+ args: [
74
+ '-p',
75
+ message,
76
+ '--provider',
77
+ 'deepseek',
78
+ '--model',
79
+ 'deepseek-v4-pro',
80
+ '--no-session',
81
+ ],
82
+ }
83
+ }
84
+ }
85
+
86
+ function onPath(program: string): boolean {
87
+ const found = spawnSync(process.platform === 'win32' ? 'where' : 'which', [program], {
88
+ encoding: 'utf8',
89
+ })
90
+ return found.status === 0
91
+ }
92
+
93
+ /** The drivers this machine can offer: the app's own agent always; a program when it is installed (pi also needs its key in the environment). */
94
+ export function availableDrivers(wanted?: readonly Exclude<DriverName, 'agent'>[]): DriverName[] {
95
+ const found: DriverName[] = ['agent']
96
+ for (const driver of wanted ?? (['claude', 'codex', 'pi'] as const)) {
97
+ if (driver === 'pi' && !process.env.DEEPSEEK_API_KEY) continue
98
+ if (onPath(driver)) found.push(driver)
99
+ }
100
+ return found
101
+ }
102
+
103
+ /** Whether a request comes from the app's own page: a browser says so itself (`Sec-Fetch-Site: same-origin`), or the Origin is the Host. */
104
+ export function fromOwnPage(headers: Headers): boolean {
105
+ const site = headers.get('sec-fetch-site')
106
+ if (site !== null) return site === 'same-origin'
107
+ const origin = headers.get('origin')
108
+ const host = headers.get('host')
109
+ return origin !== null && host !== null && new URL(origin).host === host
110
+ }
111
+
112
+ export function apply(ctx: Context, config: PanelConfig = {}): void {
113
+ if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off') return
114
+ const root = ctx.appInstance.root
115
+ const token = randomBytes(24).toString('hex')
116
+ const auditFile = join(root, '.acryl', 'audit', 'agent-panel.jsonl')
117
+ const audit = (entry: Record<string, unknown>): void => {
118
+ try {
119
+ mkdirSync(dirname(auditFile), { recursive: true })
120
+ appendFileSync(auditFile, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`)
121
+ } catch {
122
+ // The audit never stops a run.
123
+ }
124
+ }
125
+ const drivers = availableDrivers(config.drivers)
126
+ let running = false
127
+
128
+ const tag = `<script type="module" src="${PREFIX}/panel.js"></script>`
129
+ const app = new Acryl()
130
+ // The script tag, on every HTML page the routes mounted after this plugin answer.
131
+ .use('*', async (c, next) => {
132
+ await next()
133
+ if (!(c.res.headers.get('content-type') ?? '').includes('text/html')) return
134
+ const html = await c.res.text()
135
+ const headers = new Headers(c.res.headers)
136
+ headers.delete('content-length')
137
+ c.res = new Response(
138
+ html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag,
139
+ { status: c.res.status, headers },
140
+ )
141
+ })
142
+ .get(`${PREFIX}/panel.js`, (c) =>
143
+ c.body(`import '${PREFIX}/client/main.ts'\n`, 200, {
144
+ 'content-type': 'text/javascript; charset=utf-8',
145
+ 'cache-control': 'no-cache',
146
+ }),
147
+ )
148
+ .route(`${PREFIX}/client`, clientModules({ root: CLIENT_DIR }))
149
+ .get(`${PREFIX}/session`, (c) => {
150
+ if (!fromOwnPage(c.req.raw.headers))
151
+ return c.json({ error: "only the app's own page may use the panel" }, 403)
152
+ return c.json({ token, drivers })
153
+ })
154
+ .post(`${PREFIX}/ask`, async (c) => {
155
+ if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
156
+ return c.json({ error: "only the app's own page may ask" }, 403)
157
+ const body = (await c.req.json().catch(() => ({}))) as { message?: unknown; driver?: unknown }
158
+ const message = typeof body.message === 'string' ? body.message.trim() : ''
159
+ const driver = (typeof body.driver === 'string' ? body.driver : 'agent') as DriverName
160
+ if (message === '' || message.length > 8000)
161
+ return c.json({ error: 'write a message of at most 8000 characters' }, 400)
162
+ if (!drivers.includes(driver))
163
+ return c.json(
164
+ { error: `the driver "${driver}" is not available here (${drivers.join(', ')})` },
165
+ 400,
166
+ )
167
+ if (running) return c.json({ error: 'a run is already going; wait for it to end' }, 409)
168
+ running = true
169
+ return streamSSE(c, async (stream) => {
170
+ const send = (event: string, data: unknown): Promise<void> =>
171
+ stream.writeSSE({ event, data: JSON.stringify(data) })
172
+ let changed = false
173
+ const off = ctx.on('extensions/changed', (extension: string, action: string) => {
174
+ changed = true
175
+ void send('change', { extension, action })
176
+ })
177
+ audit({ ask: 'start', driver, characters: message.length })
178
+ try {
179
+ if (driver === 'agent') {
180
+ const result = await ctx.agent.run(message)
181
+ for (const call of result.trace)
182
+ await send('tool', {
183
+ tool: call.tool,
184
+ input: call.input,
185
+ ...(call.error ? { error: call.error } : { result: call.result }),
186
+ })
187
+ await send('text', {
188
+ text:
189
+ result.status === 'completed'
190
+ ? result.output
191
+ : result.status === 'cancelled'
192
+ ? 'The run was cancelled.'
193
+ : `The run stopped after ${result.steps} steps without an answer.`,
194
+ })
195
+ } else {
196
+ const { file, args } = command(driver, message, root)
197
+ const child = spawn(file, args, {
198
+ cwd: root,
199
+ env: process.env,
200
+ stdio: ['ignore', 'pipe', 'pipe'],
201
+ })
202
+ stream.onAbort(() => {
203
+ child.kill('SIGTERM')
204
+ })
205
+ child.stdout.on(
206
+ 'data',
207
+ (chunk: Buffer) => void send('text', { text: chunk.toString() }),
208
+ )
209
+ child.stderr.on(
210
+ 'data',
211
+ (chunk: Buffer) =>
212
+ void send('tool', { tool: driver, info: chunk.toString().slice(0, 500) }),
213
+ )
214
+ const code = await new Promise<number>((done) => {
215
+ child.on('error', () => done(1))
216
+ child.on('exit', (exit) => done(exit ?? 1))
217
+ })
218
+ if (code !== 0)
219
+ await send('error', { message: `${driver} ended with exit code ${code}` })
220
+ }
221
+ await send('done', { changed })
222
+ } catch (error) {
223
+ await send('error', { message: error instanceof Error ? error.message : String(error) })
224
+ } finally {
225
+ off()
226
+ running = false
227
+ audit({ ask: 'end', driver, changed })
228
+ }
229
+ })
230
+ })
231
+ ctx.router.route('/', app)
232
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "agent-panel",
3
+ "description": "A chat panel inside the running app: the person writes what they want, the app's own agent (or Claude Code, Codex or pi started in the app folder) builds it through the same door, and the page reloads when a plugin was installed, with no restart. Development only; opt-in with acrylblends add agent-panel.",
4
+ "requires": [
5
+ "acryl-router",
6
+ "acryl-node-server",
7
+ "agent",
8
+ "builder",
9
+ "extensions",
10
+ "sandbox",
11
+ "tasks",
12
+ "instance"
13
+ ],
14
+ "inject": ["router", "agent", "extensions", "appInstance"],
15
+ "provides": [],
16
+ "dependencies": {
17
+ "@deepseek-ai/cordis": "4.0.4"
18
+ },
19
+ "mount": "add a row `- id: agent-panel` / `name: agent-panel` to blend.yaml BEFORE the `routes` row (its middleware adds one script tag to every HTML page the routes after it answer) and register it in src/app.ts: 'agent-panel': panel; open the app in a browser and use the button at the corner of the page",
20
+ "gitignore": [".acryl/"]
21
+ }
@@ -5,7 +5,7 @@
5
5
  "apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
6
6
  "apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
7
7
  },
8
- "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
8
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
9
9
  },
10
10
  "rewrittenSpecifiers": 0,
11
11
  "digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
@@ -5,7 +5,7 @@
5
5
  "apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
6
6
  "apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
7
7
  },
8
- "commit": "f132283c43337f5e1d58dfbbbc15786efb5e756c"
8
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
9
9
  },
10
10
  "rewrittenSpecifiers": 0,
11
11
  "digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
@@ -5,9 +5,9 @@
5
5
  * (ACRYL needed the same fallback on Deno); the returned function removes the hook.
6
6
  *
7
7
  * The framework plugins are not packages in an app (they are source under `plugins/`), so `aliases` names the files a framework specifier such as
8
- * `acryl-router` stands for. A staged plugin then imports the same module the app itself runs, so `App` is the very class the router mounts.
8
+ * `acryl-router` stands for (and the ports and providers beside it: `acryl-kv`, `acryl-kv-json`, `acryl-db`, ..., and each as `#acryl-kv` ... the name the app's `package.json` "imports" gives them). A staged plugin then imports the same module the app itself runs, so `App` is the very class the router mounts.
9
9
  */
10
- import { existsSync, mkdirSync, realpathSync } from 'node:fs'
10
+ import { existsSync, mkdirSync, readdirSync, realpathSync } from 'node:fs'
11
11
  import { registerHooks } from 'node:module'
12
12
  import { join } from 'node:path'
13
13
  import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -90,6 +90,20 @@ export function routerAliases(): Record<string, string> {
90
90
  add('acryl-router', 'index.ts')
91
91
  add('acryl-router/middleware', 'middleware/index.ts')
92
92
  add('acryl-router/helpers', 'helpers/index.ts')
93
+ // The ports and their providers sit beside the router as source too (`acrylblends add kv` copies `acryl-kv` and `acryl-kv-json`). A plugin installed live, a
94
+ // provider of your own for example, imports them by name and gets the very modules the app runs.
95
+ const plugins = new URL('../', import.meta.url)
96
+ for (const folder of readdirSync(fileURLToPath(plugins), { withFileTypes: true })) {
97
+ if (!folder.isDirectory() || !folder.name.startsWith('acryl-')) continue
98
+ const entry = fileURLToPath(new URL(`${folder.name}/src/index.ts`, plugins))
99
+ if (!existsSync(entry)) continue
100
+ // The bare name, and the app's own `#name` (a `package.json` "imports" entry that `acrylblends add` writes): a staged copy lives in the app's home, which may be
101
+ // outside the app folder, where Node would not find that entry.
102
+ aliases[folder.name] ??= entry
103
+ aliases[`#${folder.name}`] = entry
104
+ const suite = fileURLToPath(new URL(`${folder.name}/src/conformance.ts`, plugins))
105
+ if (existsSync(suite)) aliases[`#${folder.name}/conformance`] = suite
106
+ }
93
107
  return aliases
94
108
  }
95
109
 
@@ -26,6 +26,12 @@
26
26
  "acryl-db-sqlite/tests/loader.spec.ts",
27
27
  "acryl-kv-json/tests/loader.spec.ts",
28
28
  "acryl-kv-db/tests/loader.spec.ts",
29
+ "acryl-door/tests/harness.ts",
30
+ "acryl-door/tests/host/loader.spec.ts",
31
+ "acryl-door/tests/host/door.spec.ts",
32
+ "acryl-door/tests/host/shim.spec.ts",
33
+ "acryl-door/tests/host/verbs.spec.ts",
34
+ "acryl-door/tests/page",
29
35
  "acryl-state/src/react.ts",
30
36
  "acryl-state/tests/react.spec.ts",
31
37
  "acryl-router-http/tests/adapter.spec.ts",
@@ -6,8 +6,8 @@ and swap it at the one place that mounts it.
6
6
 
7
7
  | Choice | Default | To swap it |
8
8
  | --- | --- | --- |
9
- | Records | none until you add one: `npx acrylblends add db` mounts the `db` port on SQLite | mount another provider of `db` in `src/app.ts`; plugins use `ctx.db` and do not change |
10
- | Small state | none until you add one: `npx acrylblends add kv` mounts the `kv` port on one JSON file | mount `acryl-kv-db` (the same port over `db`) or a provider of your own |
9
+ | Records | the `db` port on SQLite (already mounted in the notes and chat templates; elsewhere `npx acrylblends add db`) | mount another provider of `db` in `src/app.ts`; plugins use `ctx.db` and do not change |
10
+ | Small state | the `kv` port on one JSON file (`npx acrylblends add kv`) | mount `acryl-kv-db` (the same port over `db`) or a provider of your own |
11
11
  | Server | Node (`acryl-node-server`) | mount the adapter of another runtime in `src/app.ts` |
12
12
  | Window | the browser | `npx acrylblends add desktop` adds an Electron window on the same server |
13
13
 
@@ -23,11 +23,11 @@ install a second time). Do not create `src/plugins/` or `extensions/`.
23
23
 
24
24
  ## The door
25
25
 
26
- The door is a command in this folder, `.acryl/bin/acryl`, that lets you look at and change the running app; it reaches only the app running from this folder. Its state (a secret and an audit log) lives under `.acryl/`, which git ignores: never commit it. If `.acryl/bin/acryl` is not here, this app has no door yet: run it with its scripts and check it with `npm test`.
26
+ The door is a command in this folder, `.acryl/bin/acryl`, that lets you look at and change the running app. The running app writes it, and it reaches only the app running from this folder. Its state (a secret and an audit log) lives under `.acryl/`, which git ignores: never commit it. It is on in development and off with `NODE_ENV=production` or `ACRYL_DOOR=off`; one process at a time serves a folder's door, so a second `npm start` in the same folder stops with a message that says so (the tests set `ACRYL_DOOR=off`). If `.acryl/bin/acryl` is not here, the app is not running: start it with `npm run dev` (in the background) and open its address in a browser.
27
27
 
28
28
  ```
29
- .acryl/bin/acryl brief what this app is: its plugins, where the docs are, the verbs
30
- .acryl/bin/acryl look a snapshot of the page, with refs (r1, r2, ...)
29
+ .acryl/bin/acryl brief what this app is: its plugins, where the docs are, the verbs, and whether a page is open
30
+ .acryl/bin/acryl look the page as text, with refs (1.1, 1.2, ...): roles, names, values, visible text
31
31
  .acryl/bin/acryl act click --ref <r>
32
32
  .acryl/bin/acryl act type --ref <r> --text <t> [--submit] [--no-clear]
33
33
  .acryl/bin/acryl act select --ref <r> --option <o>
@@ -36,5 +36,10 @@ The door is a command in this folder, `.acryl/bin/acryl`, that lets you look at
36
36
  .acryl/bin/acryl act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
37
37
  .acryl/bin/acryl plugin list | add <folder> | remove <name> | reload
38
38
  .acryl/bin/acryl tool list | call --tool <name> --args '<json>'
39
+ .acryl/bin/acryl verbs the verb list
39
40
  ```
40
- Every verb takes `--json`. `plugin add plugins/<name>` checks the plugin, installs it and makes it live without a restart. `tool call` reaches only the tools on the door's allowlist (empty by default). On Windows the command is `.acryl\bin\acryl.cmd`.
41
+
42
+ - `look` and `act` need the app's page open in a browser (`brief` says `Page: not open` when it is not). Look again before each `act`: refs belong to the latest `look`.
43
+ - A password field is not shown and cannot be typed into through the door; sign in by hand, or test the sign-in with a test.
44
+ - Every verb takes `--json`. The exit code is 0 for done, 1 when the verb failed, 2 for a usage error and 3 when no app is running in this folder.
45
+ - `plugin add plugins/<name>` checks the plugin, installs it and makes it live without a restart (the `extensions` plugin does this; every template that has the door has it, in development only: it is left out with `NODE_ENV=production`). `tool call` reaches only the tools on the door's allowlist (empty by default). On Windows the command is `.acryl\bin\acryl.cmd`.
package/shared/SKILL.md CHANGED
@@ -10,5 +10,5 @@ description: How to change, run and check the __APP_NAME__ app. Use it before ad
10
10
  2. **Make the change small.** Your own routes are a plugin (`src/routes.ts`) that builds an `Acryl` app and mounts it; test a route with `await app.request('/path')`.
11
11
  3. **One plugin folder.** A plugin lives in `plugins/<name>/` (`npx acrylblends add <name>` copies a library plugin there). There is no `src/plugins/` and no `extensions/`.
12
12
  4. **Defaults are swappable.** The database, the key-value store, the server and the window are plugins behind ports. The table "Defaults you can swap" in `AGENTS.md` names each default and where it is swapped. Never reach around a port to a driver: `npx acrylblends check-deps` fails the check.
13
- 5. **Look at the running app.** The door is a command in this folder, `.acryl/bin/acryl`, that lets you look at and change the running app; it reaches only the app running from this folder. Its state (a secret and an audit log) lives under `.acryl/`, which git ignores: never commit it. If `.acryl/bin/acryl` is not here, this app has no door yet: run it with its scripts and check it with `npm test`. Start with `.acryl/bin/acryl brief`, then `look` for the page and `act` to click or type on it; the full list is in `AGENTS.md`.
13
+ 5. **Look at the running app.** Start it (`npm run dev`, in the background), open its address in a browser, then run `.acryl/bin/acryl brief`, `look` and `act`: the door shows the page as text with refs and clicks or types on it. Read the section "The door" of `AGENTS.md` for the verbs. Without the door, check the page by hand.
14
14
  6. **Done means checked.** `npm test` passes, and the change shows in the running app after a restart. No secret goes in a file or in `git status`: keys come from environment variables.
@@ -54,3 +54,6 @@ spec:
54
54
  name: acryl-node-server
55
55
  config:
56
56
  hostname: "{{parameters.hostname}}"
57
+ # The door: an agent working in this folder looks at the page and acts on it through .acryl/bin/acryl. Development only: src/app.ts leaves it out when NODE_ENV=production.
58
+ - id: acryl-door
59
+ name: acryl-door
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { Context, type Fiber } from '@deepseek-ai/cordis'
5
+ import * as door from '../plugins/acryl-door/src/index.ts'
5
6
  import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
6
7
  import * as router from '../plugins/acryl-router/src/index.ts'
7
8
  import * as state from '../plugins/acryl-state/src/index.ts'
@@ -36,6 +37,7 @@ export const registry = {
36
37
  sandbox: sandboxPlugin,
37
38
  routes,
38
39
  'acryl-node-server': nodeServer,
40
+ 'acryl-door': door,
39
41
  }
40
42
 
41
43
  /** The manifest next to this app's source. */
@@ -55,6 +57,7 @@ export function readManifest(values?: Readonly<Record<string, ParameterValue>>)
55
57
 
56
58
  export interface App {
57
59
  readonly root: Context
60
+ /** The address the server listens on; empty when the app was made with `listen: false`. */
58
61
  readonly url: string
59
62
  stop(): Promise<void>
60
63
  }
@@ -69,9 +72,22 @@ export async function createApp(
69
72
  values?: Readonly<Record<string, ParameterValue>>
70
73
  /** A real model (any OpenAI-compatible API) in place of the offline scripted one. */
71
74
  model?: OpenAIConfig
75
+ /** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
76
+ listen?: boolean
72
77
  } = {},
73
78
  ): Promise<App> {
74
- const manifest = readManifest(options.values)
79
+ const read = readManifest(options.values)
80
+ // Rows left out: the server (and so the door, which needs it) when the app is reached in process; the door in production and with ACRYL_DOOR=off (one process at a time
81
+ // serves a folder's door, so the tests that start several apps set it).
82
+ const listen = options.listen !== false
83
+ const leaveOut = new Set<string>()
84
+ if (!listen) leaveOut.add('acryl-node-server').add('acryl-door')
85
+ if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off')
86
+ leaveOut.add('acryl-door')
87
+ const manifest = {
88
+ ...read,
89
+ plugins: read.plugins.map((row) => (leaveOut.has(row.use) ? { ...row, disabled: true } : row)),
90
+ }
75
91
  const extensionsDir = join(chosen.root, 'plugins')
76
92
  // One release of grace: an app made before 0.7.8 kept what the agent wrote in `extensions/`. It is still installed, with a warning.
77
93
  const legacyDir = join(chosen.root, 'extensions')
@@ -103,7 +119,7 @@ export async function createApp(
103
119
  ]
104
120
  return {
105
121
  root,
106
- url: root.nodeServer.url,
122
+ url: listen ? root.nodeServer.url : '',
107
123
  async stop() {
108
124
  for (const fiber of [...fibers].reverse()) await fiber.dispose()
109
125
  },
@@ -7,6 +7,12 @@ import type {} from '../plugins/tasks/index.ts'
7
7
 
8
8
  export const APP_NAME = '__APP_NAME__'
9
9
 
10
+ /** The door's page script (`.acryl/bin/acryl look` and `act`), in development only: `src/app.ts` leaves the door out in production and with ACRYL_DOOR=off. */
11
+ const DOOR_TAG =
12
+ process.env.NODE_ENV !== 'production' && process.env.ACRYL_DOOR !== 'off'
13
+ ? '<script type="module" src="/api/acryl-door/page.js"></script>'
14
+ : ''
15
+
10
16
  /**
11
17
  * The app's own routes on the router: `POST /ask` for the agent; `GET /extensions` and `GET /tasks` to observe what it did (every run is a
12
18
  * durable task owning one task per tool call); `POST /tasks/cancel`. What changes is also streamed: `GET /api/state/tasks/events` and
@@ -25,7 +31,14 @@ export const routes = {
25
31
  }
26
32
  const app = new Acryl()
27
33
  .use(bodyLimit({ maxSize: 64 * 1024 }))
28
- .get('/', (c) => c.json({ app: APP_NAME, ok: true }))
34
+ // A browser gets a page (the door's and the agent panel's script tags are added where they are mounted); everything else gets the JSON.
35
+ .get('/', (c) =>
36
+ (c.req.header('accept') ?? '').includes('text/html')
37
+ ? c.html(
38
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title></head><body><h1>${APP_NAME}</h1><p>This app runs an agent. Ask it for a feature (<code>POST /ask</code>), or add the chat panel with <code>npx acrylblends add agent-panel</code>.</p>${DOOR_TAG}</body></html>`,
39
+ )
40
+ : c.json({ app: APP_NAME, ok: true }),
41
+ )
29
42
  .get('/health', (c) => c.json({ ok: true }))
30
43
  .get('/extensions', (c) =>
31
44
  c.json({