acrylblends 1.0.10 → 1.2.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 (252) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +60 -2
  3. package/dist/index.js +1992 -33
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/api/summary.md +1 -1
  7. package/docs/framework/getting-started/desktop.md +23 -4
  8. package/docs/framework/guides/create-acrylblends.md +1 -1
  9. package/docs/framework/guides/production.md +20 -0
  10. package/docs/framework/workshop/README.md +174 -285
  11. package/docs/framework/workshop/facilitator.md +81 -90
  12. package/legacy/README.md +9 -0
  13. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  14. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  15. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  16. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  17. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  18. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  19. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  20. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  21. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  22. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  23. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  24. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  25. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  26. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  27. package/legacy/agent/855359304707/src/app.ts +168 -0
  28. package/legacy/agent/855359304707/src/routes.ts +126 -0
  29. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  30. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  31. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  32. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  33. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  34. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  35. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  36. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  37. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  38. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  39. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  40. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  41. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  42. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  43. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  44. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  45. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  46. package/legacy/layouts.json +524 -0
  47. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  48. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  49. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  50. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  51. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  52. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  53. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  54. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  55. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  56. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  57. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  58. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  59. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  60. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  61. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  62. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  63. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  64. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  65. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  66. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  67. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  68. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  69. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  70. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  71. package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
  72. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  73. package/lib/cordis/door.d.ts +44 -7
  74. package/lib/cordis/door.js +214 -60
  75. package/lib/cordis/inspect.js +1 -1
  76. package/lib/cordis/node-server.d.ts +101 -3
  77. package/lib/cordis/node-server.js +3 -3
  78. package/lib/cordis/router-http.d.ts +7 -1
  79. package/lib/cordis/router-http.js +44 -3
  80. package/lib/cordis/router.d.ts +1 -1
  81. package/lib/cordis/router.js +1 -1
  82. package/lib/cordis/state.d.ts +3 -2
  83. package/lib/cordis/state.js +371 -7
  84. package/lib/door/host.d.ts +2 -2
  85. package/lib/door/host.js +1 -1
  86. package/lib/door/page.d.ts +20 -1
  87. package/lib/door/page.js +75 -14
  88. package/lib/door/protocol.d.ts +1 -1
  89. package/lib/drivers.d.ts +10 -1
  90. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  91. package/lib/node-server.d.ts +7 -1
  92. package/lib/node-server.js +2 -2
  93. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  94. package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
  95. package/lib/src-CtkIX_Gg.js +427 -0
  96. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  97. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  98. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  99. package/package.json +2 -1
  100. package/plugins/acryl-door/src/host/channel.ts +124 -9
  101. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  102. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  103. package/plugins/acryl-door/src/host/door.ts +58 -23
  104. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  105. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  106. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  107. package/plugins/acryl-door/src/index.ts +166 -29
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +11 -1
  110. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  111. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  112. package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +46 -2
  115. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  116. package/plugins/acryl-door/src/page/outline.ts +56 -0
  117. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  118. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  119. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  120. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  121. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  122. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  123. package/plugins/acryl-node-server/src/index.ts +304 -16
  124. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  125. package/plugins/acryl-page/plugin.json +12 -0
  126. package/plugins/acryl-page/src/index.ts +163 -0
  127. package/plugins/acryl-router/src/service.ts +77 -14
  128. package/plugins/acryl-router-http/src/index.ts +15 -1
  129. package/plugins/acryl-router-http/src/serve.ts +31 -0
  130. package/plugins/acryl-state/plugin.json +4 -3
  131. package/plugins/acryl-state/src/client-source.ts +244 -0
  132. package/plugins/acryl-state/src/service.ts +33 -6
  133. package/plugins/acryl-state/src/socket.ts +96 -0
  134. package/plugins/acryl-web-server/plugin.json +14 -0
  135. package/plugins/acryl-web-server/src/index.ts +164 -0
  136. package/plugins/agent/agent.ts +38 -7
  137. package/plugins/agent/index.ts +1 -1
  138. package/plugins/agent/model.ts +10 -2
  139. package/plugins/agent/tools.ts +9 -3
  140. package/plugins/agent-panel/client/describe.ts +57 -0
  141. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  142. package/plugins/agent-panel/client/frame.ts +51 -0
  143. package/plugins/agent-panel/client/main.ts +343 -81
  144. package/plugins/agent-panel/drivers.ts +7 -2
  145. package/plugins/agent-panel/headless.ts +5 -2
  146. package/plugins/agent-panel/index.ts +187 -61
  147. package/plugins/agent-panel/mount.ts +27 -0
  148. package/plugins/agent-panel/visitors.ts +231 -0
  149. package/plugins/desktop/frame.ts +231 -0
  150. package/plugins/desktop/launch.ts +44 -9
  151. package/plugins/desktop/main.ts +100 -32
  152. package/plugins/desktop/window.ts +21 -4
  153. package/plugins/driver-pty/client/terminal.ts +4 -1
  154. package/plugins/driver-pty/index.ts +38 -11
  155. package/plugins/driver-pty/spawn.ts +38 -0
  156. package/plugins/driver-pty/terminal.ts +2 -0
  157. package/plugins/extensions/index.ts +26 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/feedback/client.d.ts +22 -0
  160. package/plugins/feedback/client.js +471 -0
  161. package/plugins/feedback/index.ts +143 -0
  162. package/plugins/feedback/plugin.json +11 -0
  163. package/plugins/feedback/store.ts +307 -0
  164. package/plugins/home/index.ts +122 -0
  165. package/plugins/home/plugin.json +11 -0
  166. package/plugins/instance/index.ts +12 -3
  167. package/plugins/manifest/app-plugins.ts +86 -0
  168. package/plugins/manifest/index.ts +12 -2
  169. package/plugins/manifest/snapshot.ts +275 -0
  170. package/plugins/openai/index.ts +64 -5
  171. package/plugins/openai/prompt.ts +23 -0
  172. package/shared/AGENTS.core.md +36 -16
  173. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  174. package/shared/skills/acryl-app/SKILL.md +13 -0
  175. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  176. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  177. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  178. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  179. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  180. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  181. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  182. package/templates/agent/AGENTS.md +4 -2
  183. package/templates/agent/blend.yaml +4 -0
  184. package/templates/agent/gitignore +1 -0
  185. package/templates/agent/package.json +1 -0
  186. package/templates/agent/src/app.ts +150 -33
  187. package/templates/agent/src/frame.ts +17 -0
  188. package/templates/agent/src/main.ts +5 -3
  189. package/templates/agent/src/routes.ts +73 -21
  190. package/templates/agent/template.json +2 -0
  191. package/templates/agent/test/app.test.ts +83 -5
  192. package/templates/blank/AGENTS.md +3 -2
  193. package/templates/blank/blend.yaml +11 -0
  194. package/templates/blank/gitignore +1 -0
  195. package/templates/blank/package.json +1 -0
  196. package/templates/blank/src/app.ts +100 -29
  197. package/templates/blank/src/door.ts +7 -0
  198. package/templates/blank/src/first-page.ts +5 -0
  199. package/templates/blank/src/frame.ts +17 -0
  200. package/templates/blank/src/routes.ts +65 -8
  201. package/templates/blank/template.json +14 -1
  202. package/templates/blank/test/app.test.ts +103 -16
  203. package/templates/chat/blend.yaml +2 -1
  204. package/templates/chat/gitignore +1 -0
  205. package/templates/chat/package.json +1 -0
  206. package/templates/chat/src/app.ts +72 -22
  207. package/templates/chat/src/chat-app.ts +11 -2
  208. package/templates/chat/src/client/chat.tsx +4 -2
  209. package/templates/chat/src/frame.ts +17 -0
  210. package/templates/chat/src/main.ts +1 -6
  211. package/templates/chat/src/page.tsx +6 -4
  212. package/templates/chat/src/routes.ts +14 -4
  213. package/templates/chat/src/runtime.ts +9 -3
  214. package/templates/chat/template.json +3 -1
  215. package/templates/chat/test/chat.test.ts +46 -1
  216. package/templates/chat/test/server.test.ts +17 -3
  217. package/templates/nodejs/blend.yaml +2 -1
  218. package/templates/nodejs/gitignore +1 -0
  219. package/templates/nodejs/package.json +1 -0
  220. package/templates/nodejs/src/app.ts +76 -21
  221. package/templates/nodejs/src/frame.ts +17 -0
  222. package/templates/nodejs/src/main.ts +1 -6
  223. package/templates/nodejs/src/page.tsx +3 -3
  224. package/templates/nodejs/src/routes.ts +32 -13
  225. package/templates/nodejs/template.json +3 -1
  226. package/templates/nodejs/test/app.test.ts +38 -3
  227. package/templates/nodejs/test/server.test.ts +17 -3
  228. package/templates/notes/blend.yaml +2 -1
  229. package/templates/notes/gitignore +1 -0
  230. package/templates/notes/package.json +1 -0
  231. package/templates/notes/src/app.ts +72 -22
  232. package/templates/notes/src/frame.ts +17 -0
  233. package/templates/notes/src/main.ts +1 -6
  234. package/templates/notes/src/notes-app.ts +24 -17
  235. package/templates/notes/src/routes.ts +11 -4
  236. package/templates/notes/src/runtime.ts +9 -3
  237. package/templates/notes/src/views.tsx +26 -17
  238. package/templates/notes/template.json +3 -1
  239. package/templates/notes/test/notes.test.ts +39 -1
  240. package/templates/notes/test/server.test.ts +17 -3
  241. package/templates/todo/blend.yaml +3 -0
  242. package/templates/todo/gitignore +1 -0
  243. package/templates/todo/package.json +1 -0
  244. package/templates/todo/src/app.ts +81 -27
  245. package/templates/todo/src/door.ts +7 -0
  246. package/templates/todo/src/frame.ts +17 -0
  247. package/templates/todo/src/page.ts +13 -6
  248. package/templates/todo/src/todo.ts +18 -6
  249. package/templates/todo/template.json +11 -1
  250. package/templates/todo/test/todo.test.ts +67 -9
  251. package/lib/src-LX0E4ec4.js +0 -117
  252. package/shared/SKILL.md +0 -14
@@ -112,8 +112,11 @@ export function agentDriver(ctx: Context): ChatDriver {
112
112
  return {
113
113
  kind: 'chat',
114
114
  name: 'agent',
115
- async *start({ message, signal }) {
116
- const result = await ctx.agent.run(message, { signal })
115
+ async *start({ message, signal, page }) {
116
+ const result = await ctx.agent.run(message, {
117
+ signal,
118
+ ...(page === undefined ? {} : { page }),
119
+ })
117
120
  for (const call of result.trace)
118
121
  yield {
119
122
  event: 'tool',
@@ -7,9 +7,15 @@
7
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
8
  * `text`, `tool`, `change`, `done`, `error`.
9
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`.
10
+ * - Who asks (`visitors.ts`): in development one person, the owner, whose pages share one token and one run. As the Assistant each visitor has their own token, run and
11
+ * replay, named by an HttpOnly cookie: no visitor sees, follows or stops another's run, and the asks are bounded (per visitor per hour, per day, runs at once).
12
+ * - The routes live under the app's mount: the root of the app's own server, or the frame's prefix (`/app`) when the frame hosts it, where the host's own
13
+ * `/api` routes would otherwise answer first. The row's `mount` config names it.
14
+ * - Development only: the plugin does nothing with `NODE_ENV=production` or `ACRYL_DOOR=off`, except as the Assistant (`assistant: true`): then it runs in production too,
15
+ * docked open on the right, with the app's own agent only. The app gives that agent the page tools alone and a page-only door, so it uses the app for a visitor and
16
+ * cannot change it (src/app.ts, `ACRYL_ASSISTANT=on`). In the ACRYL frame's canvas the panel follows the frame's view: Build hides it, and Preview shows it only as
17
+ * the Assistant and only when the app's production environment names one (`assistantInProduction` in the session).
11
18
  */
12
- import { randomBytes, timingSafeEqual } from 'node:crypto'
13
19
  import { appendFileSync, mkdirSync } from 'node:fs'
14
20
  import { dirname, join } from 'node:path'
15
21
  import type { Context } from '@deepseek-ai/cordis'
@@ -19,7 +25,7 @@ import { Acryl, type MiddlewareHandler } from '../acryl-router/src/index.ts'
19
25
  import { loopbackAuthority } from '../acryl-router/src/middleware/index.ts'
20
26
  import type {} from '../agent/index.ts'
21
27
  import type {} from '../extensions/index.ts'
22
- import type { ChatDriver } from './drivers.ts'
28
+ import type { ChatDriver, Driver } from './drivers.ts'
23
29
  import {
24
30
  agentDriver,
25
31
  availableHeadless,
@@ -27,7 +33,9 @@ import {
27
33
  headlessDriver,
28
34
  whyNotChat,
29
35
  } from './headless.ts'
36
+ import { mountedBase, mountedPrefix } from './mount.ts'
30
37
  import { provideDrivers } from './registry.ts'
38
+ import { type AssistantLimits, type Visitor, Visitors } from './visitors.ts'
31
39
 
32
40
  export type {
33
41
  ChatDriver,
@@ -41,14 +49,40 @@ export type {
41
49
  export const name = 'agent-panel'
42
50
  export const inject = ['router', 'agent', 'extensions', 'appInstance']
43
51
 
44
- const PREFIX = '/api/agent-panel'
52
+ /** The panel's own routes, fixed relative to the app's mount. */
53
+ const PANEL_PATH = '/api/agent-panel'
54
+
55
+ /** The one driver the Assistant offers its visitors: the app's own agent, held to the page tools. */
56
+ const ASSISTANT_DRIVER = 'agent'
57
+
58
+ /** The slice of the door the panel uses; the door is optional (it is a development plugin, and a different row). */
59
+ interface DoorPort {
60
+ driveFor(windowId: string): () => void
61
+ }
45
62
  const CLIENT_DIR = join(import.meta.dirname, 'client')
46
63
 
47
64
  export interface PanelConfig {
48
65
  /** Headless coding agents to offer besides the app's own agent; default: the ones found on the machine. */
49
66
  drivers?: readonly HeadlessName[]
67
+ /**
68
+ * The URL prefix the app is mounted at: empty on the app's own server, `/app` when the ACRYL frame hosts it (the same prefix `makeApp` and
69
+ * `routerHttp.mount` take). The panel's routes, script tag and client live under it, so they belong to the app and are served wherever it is.
70
+ */
71
+ mount?: string
72
+ /** The Assistant: a production panel for the app's visitors, with the app's own agent only (no coding agent, no terminal). Runs with `NODE_ENV=production`. */
73
+ assistant?: boolean
74
+ /** The Assistant's limits on what visitors may ask (`visitors.ts` has the defaults); the development panel has none but one run at a time. */
75
+ limits?: AssistantLimits
50
76
  }
51
77
 
78
+ /** Whether the page came over https (directly, or through a proxy that says so): the visitor cookie is then Secure. */
79
+ const overHttps = (url: string, headers: Headers): boolean =>
80
+ url.startsWith('https:') || headers.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https'
81
+
82
+ /** The panel's routes under a checked mount: empty, or an absolute path such as `/app` with no trailing slash (the `routerHttp.mount` rule). */
83
+ const panelPrefix = (mount: string | undefined): string =>
84
+ mountedPrefix(mount, PANEL_PATH, 'agent-panel')
85
+
52
86
  /** 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. */
53
87
  export function fromOwnPage(headers: Headers): boolean {
54
88
  const site = headers.get('sec-fetch-site')
@@ -59,9 +93,24 @@ export function fromOwnPage(headers: Headers): boolean {
59
93
  }
60
94
 
61
95
  export function apply(ctx: Context, config: PanelConfig = {}): void {
62
- if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off') return
96
+ const assistant = config.assistant === true
97
+ if (process.env.ACRYL_DOOR === 'off' || (process.env.NODE_ENV === 'production' && !assistant))
98
+ return
99
+ const PREFIX = panelPrefix(config.mount)
100
+ // The panel's routes mount at the app's own mount and keep the fixed path inside (`/app` + `/api/agent-panel/...`); the frame's router prefixes the
101
+ // mount path itself, so a prefix inside the routes too would become `/app/app/...`. PREFIX stays absolute for the page's script tag and client URLs.
102
+ const MOUNT = mountedBase(config.mount, 'agent-panel')
63
103
  const root = ctx.appInstance.root
64
- const token = randomBytes(24).toString('hex')
104
+ const visitors = new Visitors<Run>({
105
+ shared: !assistant,
106
+ ...(config.limits === undefined ? {} : { limits: config.limits }),
107
+ })
108
+ /** The visitor of a request that must carry its token, or `undefined` when it does not come from the app's own page with the right one. */
109
+ const caller = (headers: Headers, presented: unknown): Visitor<Run> | undefined => {
110
+ if (!fromOwnPage(headers)) return undefined
111
+ const visitor = visitors.of(headers.get('cookie'))
112
+ return visitors.accepts(visitor, presented) ? visitor : undefined
113
+ }
65
114
  const auditFile = join(root, '.acryl', 'audit', 'agent-panel.jsonl')
66
115
  const audit = (entry: Record<string, unknown>): void => {
67
116
  try {
@@ -71,13 +120,17 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
71
120
  // The audit never stops a run.
72
121
  }
73
122
  }
123
+ // The Assistant's visitors use the app's own agent and nothing else: no plugin's driver (a terminal, a coding agent) is open to them, whatever plugins the app mounts.
74
124
  const registry = provideDrivers(
75
125
  ctx,
76
- (headers) => fromOwnPage(headers) && headers.get('x-agent-panel-token') === token,
126
+ (headers) => !assistant && caller(headers, headers.get('x-agent-panel-token')) !== undefined,
77
127
  )
128
+ const offered = (): Driver[] =>
129
+ registry.list().filter((driver) => !assistant || driver.name === ASSISTANT_DRIVER)
78
130
  ctx.effect(() => registry.register(agentDriver(ctx)))
79
- for (const program of availableHeadless(config.drivers))
80
- ctx.effect(() => registry.register(headlessDriver(program, root)))
131
+ if (!assistant)
132
+ for (const program of availableHeadless(config.drivers))
133
+ ctx.effect(() => registry.register(headlessDriver(program, root)))
81
134
 
82
135
  const tag = `<script type="module" src="${PREFIX}/panel.js"></script>`
83
136
  const addScript = async (c: { res: Response }, next: () => Promise<void>): Promise<void> => {
@@ -105,58 +158,95 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
105
158
  if (wrapped) return next()
106
159
  await addScript(c, next)
107
160
  })
108
- .get(`${PREFIX}/panel.js`, (c) =>
109
- c.body(`import '${PREFIX}/client/main.ts'\n`, 200, {
110
- 'content-type': 'text/javascript; charset=utf-8',
111
- 'cache-control': 'no-cache',
112
- }),
161
+ .get(`${PANEL_PATH}/panel.js`, (c) =>
162
+ c.body(
163
+ `import { start } from '${PREFIX}/client/main.ts'\nstart(${JSON.stringify(PREFIX)})\n`,
164
+ 200,
165
+ {
166
+ 'content-type': 'text/javascript; charset=utf-8',
167
+ 'cache-control': 'no-cache',
168
+ },
169
+ ),
113
170
  )
114
- .route(`${PREFIX}/client`, clientModules({ root: CLIENT_DIR }))
115
- .get(`${PREFIX}/session`, (c) => {
171
+ .route(`${PANEL_PATH}/client`, clientModules({ root: CLIENT_DIR, mount: `${PREFIX}/client` }))
172
+ .get(`${PANEL_PATH}/session`, (c) => {
116
173
  if (!fromOwnPage(c.req.raw.headers))
117
174
  return c.json({ error: "only the app's own page may use the panel" }, 403)
175
+ const { visitor, setCookie } = visitors.session(
176
+ c.req.header('cookie'),
177
+ overHttps(c.req.url, c.req.raw.headers),
178
+ )
179
+ if (setCookie !== undefined) c.header('set-cookie', setCookie)
118
180
  return c.json({
119
- token,
120
- drivers: registry.list().map((driver) => ({ name: driver.name, kind: driver.kind })),
181
+ token: visitor.token,
182
+ ...(assistant ? { assistant: true } : {}),
183
+ // The frame's Preview shows the app as production would. This is the fact that lets its page show the Assistant when the app's production
184
+ // environment names it (ACRYL_ASSISTANT=on), even though the frame host itself runs in development and never mounts the Assistant row.
185
+ ...(process.env.ACRYL_ASSISTANT === 'on' ? { assistantInProduction: true } : {}),
186
+ // A terminal driver names the browser module that draws its view and the prefix its endpoints live under: the plugin that owns the view
187
+ // also knows where the app is mounted, so the panel never guesses either.
188
+ drivers: offered().map((driver) => ({
189
+ name: driver.name,
190
+ kind: driver.kind,
191
+ ...(driver.kind === 'terminal' ? { view: driver.view } : {}),
192
+ })),
121
193
  })
122
194
  })
123
- .post(`${PREFIX}/ask`, async (c) => {
124
- if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
125
- return c.json({ error: "only the app's own page may ask" }, 403)
126
- const body = (await c.req.json().catch(() => ({}))) as { message?: unknown; driver?: unknown }
127
- const checked = checkAsk(body.message, body.driver)
195
+ .post(`${PANEL_PATH}/ask`, async (c) => {
196
+ const visitor = caller(c.req.raw.headers, c.req.header('x-agent-panel-token'))
197
+ if (visitor === undefined) return c.json({ error: "only the app's own page may ask" }, 403)
198
+ const body = (await c.req.json().catch(() => ({}))) as {
199
+ message?: unknown
200
+ driver?: unknown
201
+ page?: unknown
202
+ }
203
+ const checked = checkAsk(visitor, body.message, body.driver)
128
204
  if ('error' in checked) return c.json({ error: checked.error }, checked.status)
129
- const run = startRun(checked.driverName, checked.message, checked.driver)
205
+ const run = startRun(
206
+ visitor,
207
+ checked.driverName,
208
+ checked.message,
209
+ checked.driver,
210
+ pageOf(body.page),
211
+ )
130
212
  return follow(c, run, false)
131
213
  })
132
214
  // Stop: the only thing that cancels a run. A page that goes away (a reload, a move to another page, a closed tab) does not: the agent goes on, and the page picks it up again.
133
- .post(`${PREFIX}/stop`, (c) => {
134
- if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
215
+ .post(`${PANEL_PATH}/stop`, (c) => {
216
+ const visitor = caller(c.req.raw.headers, c.req.header('x-agent-panel-token'))
217
+ if (visitor === undefined)
135
218
  return c.json({ error: "only the app's own page may stop a run" }, 403)
136
- current?.cancel.abort()
219
+ // A visitor's own run, never another's.
220
+ visitor.current?.cancel.abort()
137
221
  return c.json({ ok: true })
138
222
  })
139
223
  // The latest run, for a page that just loaded: what was asked, then every event so far, then the rest as it happens (until the run ends).
140
- .get(`${PREFIX}/run`, (c) => {
141
- if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
224
+ .get(`${PANEL_PATH}/run`, (c) => {
225
+ const visitor = caller(c.req.raw.headers, c.req.header('x-agent-panel-token'))
226
+ if (visitor === undefined)
142
227
  return c.json({ error: "only the app's own page may read the run" }, 403)
143
- return current === undefined ? c.json({ run: null }) : follow(c, current, true)
228
+ return visitor.current === undefined
229
+ ? c.json({ run: null })
230
+ : follow(c, visitor.current, true)
144
231
  })
145
- /** What `/ask` and the socket's `ask` both check: a message of a sensible size, a chat driver that is here, and no run going. */
232
+ /**
233
+ * What `/ask` and the socket's `ask` both check: a message of a sensible size, a chat driver that is here, and no run of this visitor going; for the Assistant also its
234
+ * limits (an admitted ask is counted).
235
+ */
146
236
  const checkAsk = (
237
+ visitor: Visitor<Run>,
147
238
  rawMessage: unknown,
148
239
  rawDriver: unknown,
149
240
  ):
150
241
  | { message: string; driverName: string; driver: ChatDriver }
151
- | { error: string; status: 400 | 409 } => {
242
+ | { error: string; status: 400 | 409 | 429 | 503 } => {
152
243
  const message = typeof rawMessage === 'string' ? rawMessage.trim() : ''
153
244
  const driverName = typeof rawDriver === 'string' ? rawDriver : 'agent'
154
- if (message === '' || message.length > 8000)
155
- return { error: 'write a message of at most 8000 characters', status: 400 }
156
- const driver = registry.get(driverName)
245
+ const tooLong = visitors.checkMessage(message)
246
+ if (tooLong !== undefined) return tooLong
247
+ const driver = offered().find((one) => one.name === driverName)
157
248
  if (driver?.kind !== 'chat') {
158
- const chat = registry
159
- .list()
249
+ const chat = offered()
160
250
  .filter((d) => d.kind === 'chat')
161
251
  .map((d) => d.name)
162
252
  .join(', ')
@@ -166,8 +256,11 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
166
256
  status: 400,
167
257
  }
168
258
  }
169
- if (current !== undefined && !current.ended)
170
- return { error: 'a run is already going; wait for it to end', status: 409 }
259
+ const refused = visitors.admit(visitor)
260
+ if (refused !== undefined) {
261
+ if (assistant) audit({ ask: 'refused', visitor: visitor.tag, status: refused.status })
262
+ return refused
263
+ }
171
264
  return { message, driverName, driver }
172
265
  }
173
266
  /** One run of an agent: its events as they came, and who is following. It lives in the app, not in a page. */
@@ -182,9 +275,17 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
182
275
  ended: boolean
183
276
  endedAt?: number
184
277
  }
185
- let current: Run | undefined
186
278
  let runs = 0
187
- const startRun = (driverName: string, message: string, driver: ChatDriver): Run => {
279
+ /** The door's id of the page that asked, when it sent one that looks like it. */
280
+ const pageOf = (raw: unknown): string | undefined =>
281
+ typeof raw === 'string' && /^[A-Za-z0-9-]{1,80}$/u.test(raw) ? raw : undefined
282
+ const startRun = (
283
+ visitor: Visitor<Run>,
284
+ driverName: string,
285
+ message: string,
286
+ driver: ChatDriver,
287
+ page?: string,
288
+ ): Run => {
188
289
  runs += 1
189
290
  const run: Run = {
190
291
  id: runs,
@@ -196,7 +297,8 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
196
297
  cancel: new AbortController(),
197
298
  ended: false,
198
299
  }
199
- current = run
300
+ visitor.current = run
301
+ const who = assistant ? { visitor: visitor.tag } : {}
200
302
  const emit = (event: string, data: unknown): void => {
201
303
  const item = { event, data }
202
304
  run.events.push(item)
@@ -207,21 +309,31 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
207
309
  changed = true
208
310
  emit('change', { extension, action })
209
311
  })
210
- ctx.effect(() => () => run.cancel.abort(), `agent-panel: run ${runs}`)
211
- audit({ ask: 'start', driver: driverName, characters: message.length })
312
+ // The run ends when the plugin unloads; the effect goes when the run ends, so runs do not pile up on the plugin.
313
+ const unloadStops = ctx.effect(() => () => run.cancel.abort(), `agent-panel: run ${runs}`)
314
+ audit({ ask: 'start', ...who, driver: driverName, characters: message.length })
315
+ // The agent's door calls name no window: while this run goes, they are for the page whose panel asked, not for another tab or window that happens to be open.
316
+ const door = page === undefined ? undefined : (ctx.get('door') as DoorPort | undefined)
317
+ const releasePage = door?.driveFor(page as string)
212
318
  void (async () => {
213
319
  try {
214
- for await (const { event, data } of driver.start({ message, signal: run.cancel.signal }))
320
+ for await (const { event, data } of driver.start({
321
+ message,
322
+ signal: run.cancel.signal,
323
+ ...(page === undefined ? {} : { page }),
324
+ }))
215
325
  emit(event, data)
216
326
  emit('done', { changed })
217
327
  } catch (error) {
218
328
  emit('error', { message: error instanceof Error ? error.message : String(error) })
219
329
  } finally {
220
330
  off()
331
+ releasePage?.()
221
332
  run.ended = true
222
333
  run.endedAt = Date.now()
223
- audit({ ask: 'end', driver: driverName, changed })
334
+ audit({ ask: 'end', ...who, driver: driverName, changed })
224
335
  for (const follower of [...run.followers]) follower({ event: 'end', data: {} })
336
+ void unloadStops()
225
337
  }
226
338
  })()
227
339
  return run
@@ -319,17 +431,13 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
319
431
  return c.json({ error: "only the app's own page may use the panel" }, 403)
320
432
  await next()
321
433
  }
322
- const sameToken = (presented: unknown): boolean => {
323
- if (typeof presented !== 'string') return false
324
- const a = Buffer.from(presented)
325
- const b = Buffer.from(token)
326
- return a.length === b.length && timingSafeEqual(a, b)
327
- }
328
434
  ws.get(
329
435
  `${PREFIX}/run/ws`,
330
436
  own,
331
437
  upgrade(
332
- () => {
438
+ (c) => {
439
+ // The visitor the socket's cookie names (the owner in development): its hello must carry that visitor's token, and it reaches that visitor's runs only.
440
+ const visitor = visitors.of(c.req.header('cookie'))
333
441
  let authed = false
334
442
  let following: { detach(): void } | undefined
335
443
  let hello: NodeJS.Timeout | undefined
@@ -339,26 +447,35 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
339
447
  },
340
448
  onMessage(event, ws) {
341
449
  if (typeof event.data !== 'string') return
342
- let message: { t?: unknown; token?: unknown; message?: unknown; driver?: unknown }
450
+ let message: {
451
+ t?: unknown
452
+ token?: unknown
453
+ message?: unknown
454
+ driver?: unknown
455
+ page?: unknown
456
+ }
343
457
  try {
344
458
  message = JSON.parse(event.data) as typeof message
345
459
  } catch {
346
460
  return
347
461
  }
348
462
  if (!authed) {
349
- if (message.t !== 'hello' || !sameToken(message.token)) {
463
+ if (message.t !== 'hello' || !visitors.accepts(visitor, message.token)) {
350
464
  ws.close(4401, 'token')
351
465
  return
352
466
  }
353
467
  authed = true
354
468
  if (hello !== undefined) clearTimeout(hello)
469
+ // The page waits for this before it counts the socket as open: a token it was given by an earlier start of the app is refused (4401) and it asks for a new one.
470
+ ws.send(JSON.stringify({ e: 'accepted', d: {} }))
355
471
  return
356
472
  }
357
473
  const send = (e: string, d: unknown): void => {
358
474
  ws.send(JSON.stringify({ e, d }))
359
475
  }
476
+ if (visitor === undefined) return
360
477
  if (message.t === 'stop') {
361
- current?.cancel.abort()
478
+ visitor.current?.cancel.abort()
362
479
  return
363
480
  }
364
481
  if (following !== undefined) return
@@ -368,18 +485,27 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
368
485
  void pumped.done.then(() => ws.close(1000, 'run ended'))
369
486
  }
370
487
  if (message.t === 'ask') {
371
- const checked = checkAsk(message.message, message.driver)
488
+ const checked = checkAsk(visitor, message.message, message.driver)
372
489
  if ('error' in checked) {
373
490
  send('error', { message: checked.error })
374
491
  ws.close(1000, 'refused')
375
492
  return
376
493
  }
377
- watch(startRun(checked.driverName, checked.message, checked.driver), false)
494
+ watch(
495
+ startRun(
496
+ visitor,
497
+ checked.driverName,
498
+ checked.message,
499
+ checked.driver,
500
+ pageOf(message.page),
501
+ ),
502
+ false,
503
+ )
378
504
  } else if (message.t === 'follow') {
379
- if (current === undefined) {
505
+ if (visitor.current === undefined) {
380
506
  send('idle', {})
381
507
  ws.close(1000, 'no run')
382
- } else watch(current, true)
508
+ } else watch(visitor.current, true)
383
509
  }
384
510
  },
385
511
  onClose() {
@@ -393,5 +519,5 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
393
519
  )
394
520
  inner.router.route('/', ws)
395
521
  })
396
- ctx.router.route('/', app)
522
+ ctx.router.route(MOUNT === '' ? '/' : MOUNT, app)
397
523
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A plugin's own route prefix under the app's mount. The app is served at the root of its own server `''`, or at the prefix a host serves it at (`/app` in
3
+ * the ACRYL frame); a plugin that mounts routes of its own (the panel, the PTY driver) takes that mount as config and derives its prefix from it, so its
4
+ * routes belong to the app wherever the app is served. The same rule `routerHttp.mount` applies.
5
+ */
6
+ export function mountedPrefix(mount: string | undefined, path: string, plugin: string): string {
7
+ if (mount === undefined || mount === '') return path
8
+ if (!/^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/u.test(mount))
9
+ throw new TypeError(
10
+ `${plugin}: the app's mount "${mount}" must be empty or an absolute path such as "/app"`,
11
+ )
12
+ return `${mount}${path}`
13
+ }
14
+
15
+ /**
16
+ * The app's own mount, validated like {@link mountedPrefix}: empty at the root or the prefix a host serves the app at (`/app`). A plugin's routes are
17
+ * mounted at this path and keep only their own fixed path inside, so the frame's router (which prefixes a mount with the app's base) composes
18
+ * `/app` + `/api/agent-panel/panel.js` once, not twice.
19
+ */
20
+ export function mountedBase(mount: string | undefined, plugin: string): string {
21
+ if (mount === undefined || mount === '') return ''
22
+ if (!/^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/u.test(mount))
23
+ throw new TypeError(
24
+ `${plugin}: the app's mount "${mount}" must be empty or an absolute path such as "/app"`,
25
+ )
26
+ return mount
27
+ }