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,111 @@
1
+ /**
2
+ * `readSSE(response)`: the events of a `text/event-stream` answer from `fetch`, for the browser. `EventSource` reads GET streams only; a chat
3
+ * or any stream that needs a POST body, a header or an `AbortSignal` is read with `fetch`, and this is the loop every such page otherwise writes by
4
+ * hand. It follows the parsing rules of the HTML standard (lines end in LF, CRLF or CR; the `data` lines of one event join with LF; a line that
5
+ * starts with a colon is a comment; an event ends at a blank line; an event with no `data` line is not dispatched; the last event is dispatched
6
+ * only if its blank line arrived), and it is one module with no imports, so it also runs in a service worker and on a fetch runtime.
7
+ *
8
+ * ACRYL's own addition, not part of the upstream port.
9
+ */
10
+
11
+ /** One event of the stream. */
12
+ export interface SSEEvent {
13
+ /** The `event:` name; `message` when the event has none. */
14
+ readonly event: string
15
+ /** The `data:` lines of the event, joined with a line feed. */
16
+ readonly data: string
17
+ /** The `id:` of the event, when it had one. */
18
+ readonly id?: string
19
+ /** The `retry:` of the event in milliseconds, when it had one. */
20
+ readonly retry?: number
21
+ }
22
+
23
+ export interface ReadSSEOptions {
24
+ /** Stops the loop and cancels the response body when it aborts. (Abort the `fetch` itself with the signal you pass to it.) */
25
+ readonly signal?: AbortSignal
26
+ }
27
+
28
+ /**
29
+ * Read the events of a `text/event-stream` response as they arrive. Leaving the loop early (`break`, a thrown error) cancels the body, so the
30
+ * request to the server ends.
31
+ * @throws when the response is not `ok` (the status is in the message; the body is cancelled, not parsed), has no body, or fails while it is read.
32
+ */
33
+ export async function* readSSE(response: Response, options: ReadSSEOptions = {}): AsyncGenerator<SSEEvent, void, undefined> {
34
+ if (!response.ok) {
35
+ await response.body?.cancel().catch(() => undefined)
36
+ throw new Error(`readSSE: the request failed with ${String(response.status)}${response.statusText === '' ? '' : ` ${response.statusText}`}`)
37
+ }
38
+ if (response.body === null) throw new Error('readSSE: the response has no body to read')
39
+ const { signal } = options
40
+ if (signal?.aborted === true) {
41
+ await response.body.cancel().catch(() => undefined)
42
+ return
43
+ }
44
+
45
+ const reader = response.body.getReader()
46
+ const onAbort = (): void => {
47
+ void reader.cancel().catch(() => undefined)
48
+ }
49
+ signal?.addEventListener('abort', onAbort, { once: true })
50
+ const decoder = new TextDecoder('utf-8')
51
+
52
+ let buffer = ''
53
+ let skipLineFeed = false
54
+ let name = ''
55
+ let data: string[] = []
56
+ let id: string | undefined
57
+ let retry: number | undefined
58
+
59
+ /** Apply one line; returns the event when this line completes one. */
60
+ const apply = (line: string): SSEEvent | undefined => {
61
+ if (line === '') {
62
+ const event = data.length === 0 ? undefined : { event: name === '' ? 'message' : name, data: data.join('\n'), ...(id === undefined ? {} : { id }), ...(retry === undefined ? {} : { retry }) }
63
+ name = ''
64
+ data = []
65
+ id = undefined
66
+ retry = undefined
67
+ return event
68
+ }
69
+ if (line.startsWith(':')) return undefined
70
+ const colon = line.indexOf(':')
71
+ const field = colon === -1 ? line : line.slice(0, colon)
72
+ let value = colon === -1 ? '' : line.slice(colon + 1)
73
+ if (value.startsWith(' ')) value = value.slice(1)
74
+ if (field === 'event') name = value
75
+ else if (field === 'data') data.push(value)
76
+ else if (field === 'id') {
77
+ if (!value.includes('\u0000')) id = value
78
+ } else if (field === 'retry' && /^\d+$/u.test(value)) retry = Number(value)
79
+ return undefined
80
+ }
81
+
82
+ try {
83
+ for (;;) {
84
+ const { done, value } = await reader.read()
85
+ if (done) break
86
+ let text = decoder.decode(value, { stream: true })
87
+ // A CR at the end of the last chunk already ended its line; the LF that completes the CRLF may open this one.
88
+ if (skipLineFeed && text.startsWith('\n')) text = text.slice(1)
89
+ if (text !== '') skipLineFeed = false
90
+ buffer += text
91
+ let start = 0
92
+ for (let index = 0; index < buffer.length; index += 1) {
93
+ const code = buffer.charCodeAt(index)
94
+ if (code !== 10 && code !== 13) continue
95
+ const line = buffer.slice(start, index)
96
+ if (code === 13) {
97
+ if (index + 1 < buffer.length) {
98
+ if (buffer.charCodeAt(index + 1) === 10) index += 1
99
+ } else skipLineFeed = true
100
+ }
101
+ start = index + 1
102
+ const event = apply(line)
103
+ if (event !== undefined) yield event
104
+ }
105
+ buffer = buffer.slice(start)
106
+ }
107
+ } finally {
108
+ signal?.removeEventListener('abort', onAbort)
109
+ await reader.cancel().catch(() => undefined)
110
+ }
111
+ }
@@ -198,6 +198,9 @@ export class Router extends Service {
198
198
  }
199
199
  ;(handler as unknown as Record<string, unknown>)[COMPOSED_HANDLER] = inner
200
200
  tagged.on(route.method, route.path, handler)
201
+ // `on` registers under the base path of `tagged` (`/`), which would hide the prefix of a nested app from `basePath(c)`; keep the route's own.
202
+ const registered = tagged.routes.at(-1)
203
+ if (registered !== undefined) registered.basePath = route.basePath
201
204
  }
202
205
  return mount
203
206
  }
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-router",
5
- "commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
5
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -10,7 +10,7 @@
10
10
  "LICENSE"
11
11
  ],
12
12
  "rewrittenSpecifiers": 6,
13
- "digest": "sha256:07772b7e7a2cfe416e4566bebdec7e85fb4abc30466bf104e80342b9fc4ab007",
13
+ "digest": "sha256:f9679acf23d0a647a3f8bf9bb3cb9107eb03d25192ab401506e3a4ee570e305e",
14
14
  "testsNotRun": {
15
15
  "tests/loader.spec.ts": "loads the built lib/ entry through the real Loader; the library ships source and has no build"
16
16
  },
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-router-http",
5
- "commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
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": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
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
+ }
@@ -14,7 +14,7 @@ import type {} from '../instance/index.ts'
14
14
  import { builderRules } from './script.ts'
15
15
 
16
16
  export interface BuilderConfig {
17
- /** Where the agent writes extensions. Defaults to `<project>/extensions`. Must be one of the extensions sources. */
17
+ /** Where the agent writes extensions. Defaults to `<project>/plugins`. Must be one of the extensions sources. */
18
18
  dir?: string
19
19
  /** Where `package_plugin` writes standalone packages. Defaults to `<project>/plugin-packages`. */
20
20
  packagesDir?: string
@@ -64,7 +64,7 @@ export const builder = {
64
64
  name: 'builder',
65
65
  inject: ['extensions', 'tools', 'appInstance'],
66
66
  apply(ctx: Context, config: BuilderConfig) {
67
- const dir = resolve(config?.dir ?? join(ctx.appInstance.root, 'extensions'))
67
+ const dir = resolve(config?.dir ?? join(ctx.appInstance.root, 'plugins'))
68
68
  if (!ctx.extensions.sources.some((source) => resolve(source.dir) === dir)) {
69
69
  throw new BuilderError(
70
70
  `the builder writes into ${dir}, which is not an extensions source; add it to the extensions plugin's sources`,
@@ -7,5 +7,5 @@
7
7
  "dependencies": {
8
8
  "@deepseek-ai/cordis": "4.0.4"
9
9
  },
10
- "mount": "mount it after agent and extensions: root.plugin(builder, { dir: join(root, 'extensions') }); dir must be an extensions source"
10
+ "mount": "mount it after agent and extensions: root.plugin(builder, { dir: join(root, 'plugins') }); dir must be an extensions source"
11
11
  }
@@ -10,14 +10,14 @@ import { mkdirSync, readFileSync } from 'node:fs'
10
10
  import { join } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { app, BrowserWindow, shell } from 'electron'
13
- import type { App } from '../../app.ts'
13
+ import type { App } from '../../src/app.ts'
14
14
  import '../acryl-node-server/src/register.ts'
15
15
  import { selectInstance } from '../instance/index.ts'
16
16
  import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
17
17
  import { resolveDesktopUserDataOverride } from './user-data.ts'
18
18
  import { windowOptions } from './window.ts'
19
19
 
20
- const projectRoot = fileURLToPath(new URL('../../..', import.meta.url))
20
+ const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
21
21
  const chosen = selectInstance({ projectRoot, env: process.env })
22
22
 
23
23
  /** Where the window opens: `acrylblends.desktopPath` in package.json (the todo template sets `/todo`), else the root. */
@@ -65,7 +65,7 @@ if (!app.requestSingleInstanceLock()) {
65
65
 
66
66
  void app.whenReady().then(async () => {
67
67
  // The hook above is registered before the app's modules are loaded, so the app may have .tsx pages: they are loaded here, not as a static import.
68
- const { createApp } = await import('../../app.ts')
68
+ const { createApp } = await import('../../src/app.ts')
69
69
  running = await createApp(chosen)
70
70
  const origin = new URL(running.url).origin
71
71
  window = new BrowserWindow(windowOptions({ title: chosen.name }))
@@ -9,8 +9,8 @@
9
9
  "electron": "44.9.3"
10
10
  },
11
11
  "scripts": {
12
- "desktop": "electron src/plugins/desktop/main.ts"
12
+ "desktop": "electron plugins/desktop/main.ts"
13
13
  },
14
14
  "omit": ["**/*.test.*"],
15
- "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in src/plugins/desktop)"
15
+ "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in plugins/desktop)"
16
16
  }
@@ -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": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
8
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
9
9
  },
10
10
  "rewrittenSpecifiers": 0,
11
11
  "digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"